# frozen_string_literal: true

require 'spec_helper'

RSpec.describe LooseForeignKeys::ProcessDeletedRecordsService, feature_category: :database do
  include MigrationsHelpers

  def create_table_structure
    migration = ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers)

    migration.create_table :_test_loose_fk_parent_table_1
    migration.create_table :_test_loose_fk_parent_table_2

    migration.create_table :_test_loose_fk_child_table_1_1 do |t|
      t.bigint :parent_id
    end

    migration.create_table :_test_loose_fk_child_table_1_2 do |t|
      t.bigint :parent_id_with_different_column
    end

    migration.create_table :_test_loose_fk_child_table_2_1 do |t|
      t.bigint :parent_id
    end

    migration.track_record_deletions(:_test_loose_fk_parent_table_1)
    migration.track_record_deletions(:_test_loose_fk_parent_table_2)
  end

  let(:all_loose_foreign_key_definitions) do
    {
      '_test_loose_fk_parent_table_1' => [
        ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
          '_test_loose_fk_child_table_1_1',
          '_test_loose_fk_parent_table_1',
          {
            column: 'parent_id',
            on_delete: :async_delete,
            gitlab_schema: :gitlab_main,
            worker_class: ::LooseForeignKeys::CleanupWorker
          }
        ),
        ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
          '_test_loose_fk_child_table_1_2',
          '_test_loose_fk_parent_table_1',
          {
            column: 'parent_id_with_different_column',
            on_delete: :async_nullify,
            gitlab_schema: :gitlab_main,
            worker_class: ::LooseForeignKeys::CleanupWorker
          }
        )
      ],
      '_test_loose_fk_parent_table_2' => [
        ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
          '_test_loose_fk_child_table_2_1',
          '_test_loose_fk_parent_table_2',
          {
            column: 'parent_id',
            on_delete: :async_delete,
            gitlab_schema: :gitlab_main,
            worker_class: ::LooseForeignKeys::CleanupWorker
          }
        )
      ]
    }
  end

  let(:connection) { ::ApplicationRecord.connection }

  let(:loose_fk_parent_table_1) { table(:_test_loose_fk_parent_table_1) }
  let(:loose_fk_parent_table_2) { table(:_test_loose_fk_parent_table_2) }
  let(:loose_fk_child_table_1_1) { table(:_test_loose_fk_child_table_1_1) }
  let(:loose_fk_child_table_1_2) { table(:_test_loose_fk_child_table_1_2) }
  let(:loose_fk_child_table_2_1) { table(:_test_loose_fk_child_table_2_1) }

  before_all do
    create_table_structure
  end

  after(:all) do
    migration = ActiveRecord::Migration.new

    migration.drop_table :_test_loose_fk_parent_table_1
    migration.drop_table :_test_loose_fk_parent_table_2
    migration.drop_table :_test_loose_fk_child_table_1_1
    migration.drop_table :_test_loose_fk_child_table_1_2
    migration.drop_table :_test_loose_fk_child_table_2_1
  end

  before do
    allow(Gitlab::Database::LooseForeignKeys).to receive(:definitions_by_table)
      .and_return(all_loose_foreign_key_definitions)

    parent_record_1 = loose_fk_parent_table_1.create!
    loose_fk_child_table_1_1.create!(parent_id: parent_record_1.id)
    loose_fk_child_table_1_2.create!(parent_id_with_different_column: parent_record_1.id)

    parent_record_2 = loose_fk_parent_table_1.create!
    2.times { loose_fk_child_table_1_1.create!(parent_id: parent_record_2.id) }
    3.times { loose_fk_child_table_1_2.create!(parent_id_with_different_column: parent_record_2.id) }

    parent_record_3 = loose_fk_parent_table_2.create!
    5.times { loose_fk_child_table_2_1.create!(parent_id: parent_record_3.id) }

    loose_fk_parent_table_1.delete_all
    loose_fk_parent_table_2.delete_all
  end

  describe 'worker_class filtering' do
    let(:fake_custom_worker) { stub_const('TestModule::FakeCustomWorker', Class.new) }

    let(:default_worker_definitions) do
      {
        '_test_table_default_worker' => [
          ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
            '_test_child_table_default',
            '_test_table_default_worker',
            {
              column: 'parent_id',
              on_delete: :async_delete,
              gitlab_schema: :gitlab_main,
              worker_class: ::LooseForeignKeys::CleanupWorker
            }
          )
        ]
      }
    end

    let(:custom_worker_definitions) do
      {
        '_test_table_custom_worker' => [
          ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
            '_test_child_table_custom',
            '_test_table_custom_worker',
            {
              column: 'parent_id',
              on_delete: :async_delete,
              gitlab_schema: :gitlab_main,
              worker_class: fake_custom_worker
            }
          )
        ]
      }
    end

    let(:mixed_definitions) { default_worker_definitions.merge(custom_worker_definitions) }

    context 'when initialized with default CleanupWorker' do
      let(:service) { described_class.new(connection: connection, worker_class: ::LooseForeignKeys::CleanupWorker) }

      it 'only processes tables assigned to CleanupWorker' do
        allow(Gitlab::Database::LooseForeignKeys).to receive(:definitions_by_table)
          .and_return(mixed_definitions)

        tracked_tables = service.send(:tracked_tables)

        expect(tracked_tables).to include('_test_table_default_worker')
        expect(tracked_tables).not_to include('_test_table_custom_worker')
      end
    end

    context 'when initialized with FakeCustomWorker' do
      let(:service) { described_class.new(connection: connection, worker_class: fake_custom_worker) }

      it 'only processes tables assigned to FakeCustomWorker' do
        allow(Gitlab::Database::LooseForeignKeys).to receive(:definitions_by_table)
          .and_return(mixed_definitions)

        tracked_tables = service.send(:tracked_tables)

        expect(tracked_tables).to include('_test_table_custom_worker')
        expect(tracked_tables).not_to include('_test_table_default_worker')
      end
    end

    context 'when no tables match the worker class' do
      let(:service) { described_class.new(connection: connection, worker_class: fake_custom_worker) }

      it 'returns empty array when no tables are assigned to the worker' do
        allow(Gitlab::Database::LooseForeignKeys).to receive(:definitions_by_table)
          .and_return(default_worker_definitions)

        tracked_tables = service.send(:tracked_tables)

        expect(tracked_tables).to be_empty
      end
    end
  end

  describe '#execute' do
    def execute
      ::Gitlab::Database::SharedModel.using_connection(connection) do
        described_class.new(connection: connection).execute
      end
    end

    it 'cleans up all rows' do
      execute

      expect(loose_fk_child_table_1_1.count).to eq(0)
      expect(loose_fk_child_table_1_2.where(parent_id_with_different_column: nil).count).to eq(4)
      expect(loose_fk_child_table_2_1.count).to eq(0)
    end

    it 'returns stats for records cleaned up' do
      stats = execute

      expect(stats[:delete_count]).to eq(8)
      expect(stats[:update_count]).to eq(4)
    end

    it 'records the Apdex as success: true' do
      expect(::Gitlab::Metrics::LooseForeignKeysSlis).to receive(:record_apdex)
        .with(success: true, db_config_name: 'main')

      execute
    end

    it 'records the error rate as error: false' do
      expect(::Gitlab::Metrics::LooseForeignKeysSlis).to receive(:record_error_rate)
        .with(error: false, db_config_name: 'main')

      execute
    end

    context 'when the amount of records to clean up exceeds BATCH_SIZE' do
      before do
        stub_const('LooseForeignKeys::CleanupWorker::BATCH_SIZE', 2)
      end

      it 'cleans up everything over multiple batches' do
        expect(LooseForeignKeys::BatchCleanerService).to receive(:new).exactly(:twice).and_call_original

        execute

        expect(loose_fk_child_table_1_1.count).to eq(0)
        expect(loose_fk_child_table_1_2.where(parent_id_with_different_column: nil).count).to eq(4)
        expect(loose_fk_child_table_2_1.count).to eq(0)
      end
    end

    context 'when the amount of records to clean up exceeds the total MAX_DELETES' do
      def count_deletable_rows
        loose_fk_child_table_1_1.count + loose_fk_child_table_2_1.count
      end

      before do
        allow_next_instance_of(LooseForeignKeys::ModificationTracker) do |instance|
          allow(instance).to receive(:max_deletes).and_return(2)
        end
        stub_const('LooseForeignKeys::CleanerService::DELETE_LIMIT', 1)
      end

      it 'cleans up MAX_DELETES and leaves the rest for the next run' do
        expect { execute }.to change { count_deletable_rows }.by(-2)
        expect(count_deletable_rows).to be > 0
      end

      it 'records the Apdex as success: false' do
        expect(::Gitlab::Metrics::LooseForeignKeysSlis).to receive(:record_apdex)
          .with(success: false, db_config_name: 'main')

        execute
      end
    end

    context 'when cleanup raises an error' do
      before do
        expect_next_instance_of(::LooseForeignKeys::BatchCleanerService) do |service|
          allow(service).to receive(:execute).and_raise("Something broke")
        end
      end

      it 'records the error rate as error: true and does not increment apdex' do
        expect(::Gitlab::Metrics::LooseForeignKeysSlis).to receive(:record_error_rate)
          .with(error: true, db_config_name: 'main')
        expect(::Gitlab::Metrics::LooseForeignKeysSlis).not_to receive(:record_apdex)

        expect { execute }.to raise_error("Something broke")
      end
    end
  end
end
