# frozen_string_literal: true

require_relative 'helpers/file_helper'
require_relative 'helpers/milestones'
require_relative '../lib/generators/post_deployment_migration/post_deployment_migration_generator'

module Keeps
  # This is an implementation of ::Gitlab::Housekeeper::Keep.
  # This initializes the conversion of bigint columns for a given table.
  #
  # You can run it individually with:
  #
  # ```
  # bundle exec gitlab-housekeeper -d -k Keeps::InitializeBigIntConversion
  # ```
  class InitializeBigIntConversion < ::Gitlab::Housekeeper::Keep
    INTEGER_COLUMNS_FILE = 'db/integer_ids_not_yet_initialized_to_bigint.yml'
    MIGRATION_TEMPLATE = 'generator_templates/active_record/migration/'
    FALLBACK_REVIEWER_FEATURE_CATEGORY = 'database'
    CLASS_WITH_NAMESPACE = /class\s+([A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)+)\s*(?:<\s*[A-Z][A-Za-z0-9:]*\s*)?$/
    CLASS_WITHOUT_NAMESPACE = /class\s+([A-Z][A-Za-z0-9]*)\s*(?:<\s*[A-Z][A-Za-z0-9:]*\s*)?$/

    TABLE_INT_IDS_YAML_FILE_COMMENT = <<~MESSAGE
      # -- DON'T MANUALLY EDIT --
      # Contains the list of integer IDs which were converted to bigint for new installations in
      # https://gitlab.com/gitlab-org/gitlab/-/issues/438124, but they are still integers for existing instances.
      # On initialize_conversion_of_integer_to_bigint those integer IDs will be removed automatically from here.
    MESSAGE

    def initialize(...)
      ::PostDeploymentMigration::PostDeploymentMigrationGenerator.source_root(MIGRATION_TEMPLATE)

      reset_db
      migrate

      super
    end

    def each_identified_change
      integer_columns_to_migrate.each do |table_name, columns|
        change = ::Gitlab::Housekeeper::Change.new
        change.identifiers = [self.class.name.demodulize, table_name]
        change.context = { table_name: table_name, columns: columns }
        yield(change)
      end
    end

    def make_change!(change)
      table_name = change.context[:table_name]
      columns = change.context[:columns]
      build_change_details(change, table_name, columns)
      change.changed_files = []
      migration_file_1, migration_number_1 = generate_initialization_migration_file(table_name, columns)
      migration_file_2, migration_number_2 = generate_backfill_migration_file(table_name, columns)

      change.changed_files << migration_file_1
      change.changed_files << migration_file_2
      change.changed_files << Pathname.new('db').join('schema_migrations', migration_number_1).to_s
      change.changed_files << Pathname.new('db').join('schema_migrations', migration_number_2).to_s

      file_path = update_model(table_name, columns)
      update_integer_columns_file(table_name)

      migrate
      change.changed_files << Pathname.new('db').join('structure.sql').to_s
      change.changed_files << file_path
      change.changed_files << INTEGER_COLUMNS_FILE

      reset_db
      change
    end

    private

    def integer_columns_to_migrate
      YAML.safe_load_file(INTEGER_COLUMNS_FILE)
    end

    def build_change_details(change, table_name, columns)
      change.title = "Prepare conversion of #{table_name} to bigint".truncate(70, omission: '')
      change.changelog_type = 'added'
      change.labels = labels(table_name)
      change.reviewers = pick_reviewers(table_name, change.identifiers).uniq

      change.description = <<~MARKDOWN
      Prepares conversion of `#{table_name}` to bigint for `#{columns.join(', ')}`

      You can read more about the process for preparing bigint conversion in
      https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#migrating-integer-primary-keys-to-bigint.

      As part of our process we want to ensure all ID columns are bigint to avoid the risk of overflowing while we continue our growth.

      See https://gitlab.com/gitlab-org/gitlab/-/issues/465805+

      Verify this MR as it was automatically created by `gitlab-housekeeper`.

      Ensure that those columns are not being converted yet in the production database by checking Joe Bot through https://console.postgres.ai/gitlab.
      If the columns were already converted in another merge request, consider closing this merge request
      MARKDOWN

      change
    end

    def labels(table_name)
      table_info = Gitlab::Database::Dictionary.entries.find_by_table_name(table_name)

      group_labels = table_info.feature_categories.flat_map do |feature_category|
        groups_helper.labels_for_feature_category(feature_category)
      end

      group_labels << 'maintenance::scalability'
    end

    def pick_reviewers(table_name, identifiers)
      table_info = Gitlab::Database::Dictionary.entries.find_by_table_name(table_name)

      table_info.feature_categories.map do |feature_category|
        groups_helper.pick_reviewer_for_feature_category(feature_category, identifiers,
          fallback_feature_category: FALLBACK_REVIEWER_FEATURE_CATEGORY)
      end
    end

    def generate_initialization_migration_file(table_name, columns)
      migration_name = "initialize_conversion_of_#{table_name}_to_bigint".truncate(100, omission: '')
      generator = ::PostDeploymentMigration::PostDeploymentMigrationGenerator.new([migration_name])

      migration_content = <<~RUBY.strip
        disable_ddl_transaction!

        TABLE_NAME = :#{table_name}
        COLUMNS = %i[#{columns.join(' ')}]

        def up
          initialize_conversion_of_integer_to_bigint(TABLE_NAME, COLUMNS, primary_key: :#{primary_key(table_name)})
        end

        def down
          revert_initialize_conversion_of_integer_to_bigint(TABLE_NAME, COLUMNS)
        end
      RUBY

      migration_file = generator.invoke_all.first
      file_helper = ::Keeps::Helpers::FileHelper.new(migration_file)
      file_helper.replace_method_content(:change, migration_content, strip_comments_from_file: true)

      ::Gitlab::Housekeeper::Shell.execute('rubocop', '-a', migration_file)

      [migration_file, generator.migration_number]
    end

    def generate_backfill_migration_file(table_name, columns)
      migration_name = "backfill_#{table_name}_for_bigint_conversion".truncate(100, omission: '')
      generator = ::PostDeploymentMigration::PostDeploymentMigrationGenerator.new([migration_name])
      gitlab_schema = Gitlab::Database::Dictionary.entries.find_by_table_name(table_name).gitlab_schema

      migration_content = <<~RUBY.strip
        disable_ddl_transaction!
        restrict_gitlab_migration gitlab_schema: :#{gitlab_schema}

        TABLE_NAME = :#{table_name}
        COLUMNS = %i[#{columns.join(' ')}]

        def up
          backfill_conversion_of_integer_to_bigint(TABLE_NAME, COLUMNS, primary_key: :#{primary_key(table_name)})
        end

        def down
          revert_backfill_conversion_of_integer_to_bigint(TABLE_NAME, COLUMNS)
        end
      RUBY

      migration_file = generator.invoke_all.first
      file_helper = ::Keeps::Helpers::FileHelper.new(migration_file)
      file_helper.replace_method_content(:change, migration_content, strip_comments_from_file: true)

      ::Gitlab::Housekeeper::Shell.execute('rubocop', '-a', migration_file)

      [migration_file, generator.migration_number]
    end

    def model_path(table_name)
      model = Gitlab::Database::Dictionary.entries.find_by_table_name(table_name).classes.first
      nested_model = model.underscore
      file_path = Rails.root.join('app', 'models', "#{nested_model}.rb").to_s

      if File.exist?(file_path)
        file_path
      else
        Rails.root.join('ee', 'app', 'models', "#{nested_model}.rb").to_s
      end
    end

    def update_model(table_name, columns)
      class_name = Gitlab::Database::Dictionary.entries.find_by_table_name(table_name).classes.first.split("::").last
      file_path = model_path(table_name)

      ignore_columns = columns.map do |column|
        "ignore_column :#{column}_convert_to_bigint, remove_with: '#{n_3_milestone.version}', " \
          "remove_after: '#{n_2_milestone.date}'"
      end

      new_content = <<~RUBY
        #{ignore_columns.join("\n")}
      RUBY

      insert_after_class_definition(file_path, class_name, new_content)
      ::Gitlab::Housekeeper::Shell.execute('rubocop', '-a', file_path)

      file_path
    end

    def insert_after_class_definition(file_path, class_name, new_content)
      content = File.read(file_path)
      pattern = class_name.include?('::') ? CLASS_WITH_NAMESPACE : CLASS_WITHOUT_NAMESPACE

      matches = content.scan(pattern)
      return unless matches.flatten.include?(class_name)

      updated_content = content.gsub(pattern) do |match|
        if ::Regexp.last_match(1) == class_name
          "#{match}\n#{new_content}\n"
        else
          match
        end
      end

      File.write(file_path, updated_content)
    end

    def update_integer_columns_file(table_name)
      file_path = Rails.root.join(INTEGER_COLUMNS_FILE)
      data = YAML.safe_load_file(file_path)
      data.delete(table_name)

      File.open(INTEGER_COLUMNS_FILE, 'w') do |f|
        f.write(TABLE_INT_IDS_YAML_FILE_COMMENT)
        f.write(data.to_yaml)
      end
    end

    def migrate
      ::Gitlab::Housekeeper::Shell.execute('rails', 'db:migrate', env: { 'RAILS_ENV' => 'test' })
    end

    def reset_db
      ApplicationRecord.connection_handler.clear_all_connections!
      ::Gitlab::Housekeeper::Shell.execute('rails', 'db:reset', env: { 'RAILS_ENV' => 'test' })
    end

    def n_2_milestone
      milestones_helper.upcoming_milestones[2]
    end

    def n_3_milestone
      milestones_helper.upcoming_milestones[3]
    end

    def groups_helper
      @groups_helper ||= ::Keeps::Helpers::Groups.new
    end

    def milestones_helper
      @milestones_helper ||= ::Keeps::Helpers::Milestones.new
    end

    def primary_key(table_name)
      Gitlab::Database::Dictionary.entries.find_by_table_name(table_name).classes.first.constantize.primary_key
    end
  end
end
