require 'unit_spec_helper'

describe Shoulda::Matchers::ActiveModel::ValidateInclusionOfMatcher, type: :model do
  shared_context 'for a generic attribute' do
    context 'against an integer attribute' do
      it_behaves_like 'it supports in_array',
        possible_values: (1..5).to_a,
        zero: 0,
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_INTEGER

      it_behaves_like 'it supports in_range',
        possible_values: 1..5,
        zero: 0

      context 'when attribute validates a range of values via custom validation' do
        it 'matches ensuring the correct range and messages' do
          expect_to_match_ensuring_range_and_messages(2..5, 2, 5)
          expect_to_match_ensuring_range_and_messages(2...5, 2, 4)
        end
      end

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(column_type: :integer, value: 1),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :integer, default_value: 1)
      end
    end

    context 'against an attribute with a specific column limit' do
      it 'does not raise an exception when attempting to use the matcher' do
        possible_values = (1..5).to_a
        builder = build_object_allowing(possible_values)
        assertion = -> { expect_to_match_on_values(builder, possible_values) }
        expect(&assertion).not_to raise_error
      end

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(
            column_type: :integer,
            column_options: { limit: 2 },
            value: 1,
          ),
          &block
        )
      end

      def expect_to_match_on_values(builder, values, &block)
        expect_to_match_in_array(builder, values, &block)
      end
    end

    context 'against a float attribute' do
      it_behaves_like 'it supports in_array',
        possible_values: [1.0, 2.0, 3.0, 4.0, 5.0],
        zero: 0.0,
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_INTEGER

      it_behaves_like 'it supports in_range',
        possible_values: 1.0..5.0,
        zero: 0.0

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(column_type: :float, value: 1.0),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :float, default_value: 1.0)
      end
    end

    context 'against a decimal attribute' do
      it_behaves_like 'it supports in_array',
        possible_values: [1.0, 2.0, 3.0, 4.0, 5.0].map { |number|
          BigDecimal(number.to_s)
        },
        zero: BigDecimal('0.0'),
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_DECIMAL

      it_behaves_like 'it supports in_range',
        possible_values: BigDecimal('1.0')..BigDecimal('5.0'),
        zero: BigDecimal('0.0')

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(column_type: :decimal, value: BigDecimal('1.0')),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(
          column_type: :decimal,
          default_value: BigDecimal('1.0'),
        )
      end
    end

    context 'against a date attribute' do
      today = Date.today

      define_method(:today) { today }

      it_behaves_like 'it supports in_array',
        possible_values: (1..5).map { |n| today + n },
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_DATE

      it_behaves_like 'it supports in_range',
        possible_values: (today..today + 5)

      define_method :build_object do |options = {}, &block|
        build_object_with_generic_attribute(
          **options.merge(column_type: :date, value: today),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :date, default_value: today)
      end
    end

    context 'against a datetime attribute' do
      now = DateTime.new(2022, 1, 1)

      define_method(:now) { now }

      it_behaves_like 'it supports in_array',
        possible_values: (1..5).map { |n| now + n },
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_DATETIME

      it_behaves_like 'it supports in_range',
        possible_values: (now..now + 5)

      define_method :build_object do |options = {}, &block|
        build_object_with_generic_attribute(
          **options.merge(column_type: :datetime, value: now),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :datetime, default_value: now)
      end
    end

    context 'against a time attribute' do
      default_time = Time.zone.local(2000, 1, 1)

      define_method(:default_time) { default_time }

      it_behaves_like 'it supports in_array',
        possible_values: (1..3).map { |hour| default_time.change(hour: hour) }

      it_behaves_like 'it supports in_range', possible_values: (
        default_time.change(hour: 1)..default_time.change(hour: 3)
      )

      define_method :build_object do |options = {}, &block|
        build_object_with_generic_attribute(
          **options.merge(column_type: :time, value: default_time),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :time, default_value: default_time)
      end
    end

    context 'against a string attribute' do
      it_behaves_like 'it supports in_array',
        possible_values: %w(foo bar baz),
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_STRING

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(column_type: :string),
          &block
        )
      end

      def add_outside_value_to(values)
        values + %w(qux)
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :string)
      end
    end
  end

  shared_examples_for 'it supports allow_nil' do |args|
    valid_values = args.fetch(:valid_values)

    it 'matches when the validation specifies allow_nil' do
      builder = build_object_allowing(
        valid_values,
        validation_options: { allow_nil: true },
      )

      # rubocop:disable Style/SymbolProc
      expect_to_match_on_values(builder, valid_values) do |matcher|
        matcher.allow_nil
      end
      # rubocop:enable Style/SymbolProc
    end

    it 'allows other qualifiers to be chained afterward' do
      builder = build_object_allowing(valid_values)

      using_matcher = -> do
        matcher = validate_inclusion_of(builder.attribute)
        matcher.allow_nil.allow_blank
      end

      expect(&using_matcher).not_to raise_error
    end

    def validation_matcher_scenario_args
      super.deep_merge(validation_options: { allow_nil: true })
    end

    def configure_validation_matcher(matcher)
      super(matcher).allow_nil
    end
  end

  shared_examples_for 'it supports allow_blank' do |args|
    valid_values = args.fetch(:valid_values)

    it 'matches when the validation specifies allow_blank' do
      builder = build_object_allowing(
        valid_values,
        validation_options: { allow_blank: true },
      )

      # rubocop:disable Style/SymbolProc
      expect_to_match_on_values(builder, valid_values) do |matcher|
        matcher.allow_blank
      end
      # rubocop:enable Style/SymbolProc
    end

    def validation_matcher_scenario_args
      super.deep_merge(validation_options: { allow_blank: true })
    end

    def configure_validation_matcher(matcher)
      super(matcher).allow_blank
    end
  end

  shared_examples_for 'it supports with_message' do |args|
    valid_values = args.fetch(:valid_values)

    context 'given a string' do
      it 'matches when validation uses given message' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: 'a message' },
        )

        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message('a message')
        end
      end

      it 'matches when validation uses given message that has an interpolated variable' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: '%{value} is not included' },
        )

        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message(/ is not included\Z/)
        end
      end

      it 'does not match when validation uses the default message instead of given message' do
        builder = build_object_allowing(valid_values)

        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message('a message')
        end
      end

      it 'does not match when validation uses a message but it is not same as given' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: 'a different message' },
        )

        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message('a message')
        end
      end
    end

    context 'given a regex' do
      it 'matches when validation uses a message that matches the regex' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: 'this is a message' },
        )

        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message(/a message/)
        end
      end

      it 'does not match when validation uses the default message instead of given message' do
        builder = build_object_allowing(valid_values)

        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message(/a message/)
        end
      end

      it 'does not match when validation uses a message but it does not match regex' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: 'a different message' },
        )

        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message(/a message/)
        end
      end
    end

    context 'given nil' do
      it 'is as if with_message had never been called' do
        builder = build_object_allowing(valid_values)

        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.with_message(nil)
        end
      end
    end

    context 'not having been qualified' do
      it 'matches when the validation uses the default message' do
        builder = build_object_allowing(valid_values)
        expect_to_match_on_values(builder, valid_values)
      end

      it 'matches when the validation uses the default message and it is overridden in i18n' do
        stubbing_translations(
          'errors.messages.inclusion' => 'some message',
          'errors.models.person.attributes.name.inclusion' => 'a different message',
        ) do
          builder = build_object_allowing(
            valid_values,
            model_name: 'Person',
            attribute_name: :name,
          )
          expect_to_match_on_values(builder, valid_values)
        end
      end

      it 'does not match when the validation is configured with an overridden message' do
        builder = build_object_allowing(
          valid_values,
          validation_options: { message: 'some message' },
        )

        expect_not_to_match_on_values(builder, valid_values)
      end
    end
  end

  shared_examples_for 'it supports in_array' do |args|
    possible_values = args.fetch(:possible_values)
    zero = args[:zero]
    reserved_outside_value = args[:reserved_outside_value]

    define_method(:valid_values) { args.fetch(:possible_values) }

    it_behaves_like 'it supports allow_nil', valid_values: possible_values
    it_behaves_like 'it supports allow_blank', valid_values: possible_values
    it_behaves_like 'it supports with_message', valid_values: possible_values

    it_supports(
      'ignoring_interference_by_writer',
      tests: {
        reject_if_qualified_but_changing_value_interferes: {
          attribute_name: :attr,
          changing_values_with: :next_value,
          expected_message_includes: <<-MESSAGE.strip,
  As indicated in the message above, :attr seems to be changing certain
  values as they are set, and this could have something to do with why
  this test is failing. If you've overridden the writer method for this
  attribute, then you may need to change it to make this test pass, or
  do something else entirely.
          MESSAGE
        },
      },
    )

    context 'when the record has no validations' do
      it 'passes when used in the negative' do
        builder = build_object
        expect_not_to_match_on_values(builder, possible_values)
      end

      it 'fails when used in the positive with an appropriate failure message' do
        builder = build_object

        assertion = lambda do
          expect_to_match_on_values(builder, possible_values)
        end

        expect(&assertion).to fail
      end
    end

    it 'matches given the same array of valid values' do
      builder = build_object_allowing(possible_values)
      expect_to_match_on_values(builder, possible_values)
    end

    it 'matches given a subset of the valid values' do
      builder = build_object_allowing(possible_values)
      expect_to_match_on_values(builder, possible_values[1..])
    end

    if zero
      it 'matches when one of the given values is a zero' do
        valid_values = possible_values + [zero]
        builder = build_object_allowing(valid_values)
        expect_to_match_on_values(builder, valid_values)
      end
    end

    it 'does not match when one of the given values is invalid' do
      builder = build_object_allowing(possible_values)
      expect_not_to_match_on_values(
        builder,
        add_outside_value_to(possible_values),
      )
    end

    if reserved_outside_value
      it 'raises an error when valid and given value is our test outside value' do
        error_class = Shoulda::Matchers::ActiveModel::CouldNotDetermineValueOutsideOfArray
        builder = build_object_allowing([reserved_outside_value])

        expect { expect_to_match_on_values(builder, [reserved_outside_value]) }.
          to raise_error(error_class)
      end
    end

    it 'fails when used in the negative' do
      builder = build_object_allowing(possible_values)

      assertion = lambda do
        expect_not_to_match_on_values(builder, possible_values)
      end

      expect(&assertion).to fail
    end

    it_behaves_like 'it supports allow_nil', valid_values: possible_values do
      it 'does not match when the validation does not specify allow_nil' do
        builder = build_object_allowing(valid_values)

        # rubocop:disable Style/SymbolProc
        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.allow_nil
        end
        # rubocop:enable Style/SymbolProc
      end
    end

    it_behaves_like 'it supports allow_blank', valid_values: possible_values do
      it 'does not match when the validation does not specify allow_blank' do
        builder = build_object_allowing(valid_values)

        # rubocop:disable Style/SymbolProc
        expect_not_to_match_on_values(builder, valid_values) do |matcher|
          matcher.allow_blank
        end
        # rubocop:enable Style/SymbolProc
      end
    end

    it_behaves_like 'it supports with_message', valid_values: possible_values

    def expect_to_match_on_values(builder, values, &block)
      expect_to_match_in_array(builder, values, &block)
    end

    def expect_not_to_match_on_values(builder, values, &block)
      expect_not_to_match_in_array(builder, values, &block)
    end

    def validation_matcher_scenario_args
      super.deep_merge(validation_options: { in: valid_values })
    end

    def configure_validation_matcher(matcher)
      super(matcher).in_array(valid_values)
    end
  end

  shared_examples_for 'it supports in_range' do |args|
    possible_values = args[:possible_values]

    define_method(:valid_values) { args.fetch(:possible_values) }

    it_behaves_like 'it supports allow_nil', valid_values: possible_values
    it_behaves_like 'it supports allow_blank', valid_values: possible_values
    it_behaves_like 'it supports with_message', valid_values: possible_values

    it_supports(
      'ignoring_interference_by_writer',
      tests: {
        reject_if_qualified_but_changing_value_interferes: {
          attribute_name: :attr,
          changing_values_with: :next_value,
          expected_message_includes: <<-MESSAGE.strip,
  As indicated in the message above, :attr seems to be changing certain
  values as they are set, and this could have something to do with why
  this test is failing. If you've overridden the writer method for this
  attribute, then you may need to change it to make this test pass, or
  do something else entirely.
          MESSAGE
        },
      },
    )

    it 'does not match a record with no validations' do
      builder = build_object
      expect_not_to_match_on_values(builder, possible_values)
    end

    it 'matches given a range that exactly matches the valid range' do
      builder = build_object_allowing(possible_values)
      expect_to_match_on_values(builder, possible_values)
    end

    it 'matches given a beginless range that covers the possible values' do
      builder = build_object_allowing(possible_values)
      expect_to_match_on_values(
        builder,
        Range.new(nil, possible_values.last),
      )
    end

    it 'matches given a endless range that covers the possible values' do
      builder = build_object_allowing(possible_values)
      expect_to_match_on_values(
        builder,
        Range.new(possible_values.first, nil),
      )
    end

    it 'does not match given a range whose start value falls outside valid range' do
      builder = build_object_allowing(possible_values)
      expect_not_to_match_on_values(
        builder,
        Range.new(possible_values.first - 1, possible_values.last),
      )
    end

    it 'does not match given a range whose start value falls inside valid range' do
      builder = build_object_allowing(possible_values)
      expect_not_to_match_on_values(
        builder,
        Range.new(possible_values.first + 1, possible_values.last),
      )
    end

    it 'does not match given a range whose end value falls inside valid range' do
      builder = build_object_allowing(possible_values)
      expect_not_to_match_on_values(
        builder,
        Range.new(possible_values.first, possible_values.last - 1),
      )
    end

    it 'does not match given a range whose end value falls outside valid range' do
      builder = build_object_allowing(possible_values)
      expect_not_to_match_on_values(
        builder,
        Range.new(possible_values.first, possible_values.last + 1),
      )
    end

    it_behaves_like 'it supports allow_nil', valid_values: possible_values do
      it 'matches when the validation does not specify allow_nil' do
        builder = build_object_allowing(valid_values)

        # rubocop:disable Style/SymbolProc
        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.allow_nil
        end
        # rubocop:enable Style/SymbolProc
      end
    end

    it_behaves_like 'it supports allow_blank', valid_values: possible_values do
      it 'matches when the validation does not specify allow_blank' do
        builder = build_object_allowing(valid_values)

        # rubocop:disable Style/SymbolProc
        expect_to_match_on_values(builder, valid_values) do |matcher|
          matcher.allow_blank
        end
        # rubocop:enable Style/SymbolProc
      end
    end

    it_behaves_like 'it supports with_message', valid_values: possible_values

    def expect_to_match_on_values(builder, range, &block)
      expect_to_match_in_range(builder, range, &block)
    end

    def expect_not_to_match_on_values(builder, range, &block)
      expect_not_to_match_in_range(builder, range, &block)
    end

    def validation_matcher_scenario_args
      super.deep_merge(validation_options: { in: valid_values })
    end

    def configure_validation_matcher(matcher)
      super(matcher).in_range(valid_values)
    end
  end

  shared_context 'against a boolean attribute for true and false' do
    context 'when ensuring inclusion of true' do
      it 'matches' do
        valid_values = [true]
        builder = build_object_allowing(valid_values)
        expect_to_match_in_array(builder, valid_values)
      end
    end

    context 'when ensuring inclusion of false' do
      it 'matches' do
        valid_values = [false]
        builder = build_object_allowing(valid_values)
        expect_to_match_in_array(builder, valid_values)
      end
    end

    context 'when ensuring inclusion of true and false' do
      it 'matches' do
        valid_values = [true, false]
        builder = build_object_allowing(valid_values)
        silence_stderr do
          expect_to_match_in_array(builder, valid_values)
        end
      end

      [[false, true], [true, false]].each do |booleans|
        it 'prints a warning' do
          valid_values = booleans
          builder = build_object_allowing(valid_values)
          message =
            'You are using `validate_inclusion_of` to assert that a boolean '\
            'column allows boolean values and disallows non-boolean ones'

          stderr = capture(:stderr) do
            expect_to_match_in_array(builder, valid_values)
          end

          expect(stderr.gsub(/\n+/, ' ')).to include(message)
        end
      end
    end
  end

  context 'for a database column' do
    include_context 'for a generic attribute'

    context 'against a timestamp column' do
      now = DateTime.new(2022, 1, 1)
      define_method(:now) { now }

      it_behaves_like 'it supports in_array',
        possible_values: (1..5).map { |n| now + n },
        reserved_outside_value: described_class::ARBITRARY_OUTSIDE_DATETIME

      it_behaves_like 'it supports in_range',
        possible_values: (now..now + 5)

      define_method :build_object do |options = {}, &block|
        build_object_with_generic_attribute(
          **options.merge(column_type: :timestamp, value: now),
          &block
        )
      end

      def add_outside_value_to(values)
        values + [values.last + 1]
      end

      def validation_matcher_scenario_args
        super.deep_merge(column_type: :timestamp, default_value: now)
      end
    end

    context 'against a boolean attribute' do
      context 'which is nullable' do
        include_context 'against a boolean attribute for true and false'

        context 'when ensuring inclusion of nil' do
          it 'matches' do
            valid_values = [nil]
            builder = build_object_allowing(valid_values)
            silence_stderr do
              expect_to_match_in_array(builder, valid_values)
            end
          end

          it 'prints a warning' do
            valid_values = [nil]
            builder = build_object_allowing(valid_values)
            message =
              'You are using `validate_inclusion_of` to assert that a '\
              'boolean column allows nil'

            stderr = capture(:stderr) do
              expect_to_match_in_array(builder, valid_values)
            end

            expect(stderr.gsub(/\n+/, ' ')).to include(message)
          end
        end

        def build_object(**options, &block)
          super(
            **options.merge(column_options: { null: true }, value: true),
            &block
          )
        end
      end

      context 'which is non-nullable' do
        include_context 'against a boolean attribute for true and false'

        context 'when ensuring inclusion of nil' do
          it 'raises a specific error' do
            valid_values = [nil]
            builder = build_object_allowing(valid_values)
            error_class = Shoulda::Matchers::ActiveModel::NonNullableBooleanError
            expect {
              expect_to_match_in_array(builder, valid_values)
            }.to raise_error(error_class)
          end
        end

        def build_object(**options, &block)
          super(**options.merge(column_options: { null: false }), &block)
        end
      end

      def build_object(**options, &block)
        build_object_with_generic_attribute(
          **options.merge(column_type: :boolean),
          &block
        )
      end
    end

    def define_simple_model(
      model_name: 'Example',
      attribute_name: :attr,
      column_options: {},
      &block
    )
      define_model(model_name, attribute_name => column_options, &block)
    end

    def validation_matcher_scenario_args
      super.deep_merge(model_creator: :active_record)
    end

    context 'against a polymorphic association' do
      it 'matches when the subject configures validate_inclusion_of' do
        define_model(:issue, severity_id: :integer, severity_type: :string) do
          belongs_to :severity, polymorphic: true, optional: true
          validates_inclusion_of :severity_type, in: %w(Low Medium High)
        end
        define_model(:high)
        define_model(:medium)
        define_model(:low)

        expect { validate_inclusion_of(:severity_type).in_array(%w(Low Medium High)) }.to match_against(Issue.new)
      end

      it 'does not match when subject does not set validate_inclusion_of' do
        define_model(:issue, severity_id: :integer, severity_type: :string) do
          belongs_to :severity, polymorphic: true, optional: true
        end
        define_model(:high)
        define_model(:medium)
        define_model(:low)

        expected_message = <<-MESSAGE.strip
Expected Issue to validate that :severity_type is either ‹"Low"›,
‹"Medium"›, or ‹"High"›, but this could not be proved.
  After setting :severity_type to ‹"Shoulda::Matchers::ExampleClass"›,
  the matcher expected the Issue to be invalid, but it was valid
  instead.
        MESSAGE

        expect { validate_inclusion_of(:severity_type).in_array(%w(Low Medium High)) }.
          not_to match_against(Issue.new).
          and_fail_with(expected_message)
      end
    end
  end

  context 'for a plain Ruby attribute' do
    include_context 'for a generic attribute'

    context 'against a boolean attribute (designated by true)' do
      include_context 'against a boolean attribute for true and false'

      def build_object(**options, &block)
        build_object_with_generic_attribute(**options.merge(value: true), &block)
      end
    end

    context 'against a boolean attribute (designated by false)' do
      include_context 'against a boolean attribute for true and false'

      def build_object(**options, &block)
        build_object_with_generic_attribute(**options.merge(value: false), &block)
      end
    end

    def define_simple_model(
      model_name: 'Example',
      attribute_name: :attr,
      **_rest,
      &block
    )
      define_active_model_class(model_name, accessors: [attribute_name], &block)
    end

    def validation_matcher_scenario_args
      super.deep_merge(model_creator: :active_model)
    end
  end

  describe '#description' do
    context 'given an array of values' do
      context 'when there is one value' do
        it 'returns the correct string' do
          matcher = validate_inclusion_of(:attr).in_array([true])

          expect(matcher.description).to eq(
            'validate that :attr is ‹true›',
          )
        end
      end

      context 'when there are two values' do
        it 'returns the correct string' do
          matcher = validate_inclusion_of(:attr).in_array([true, 'dog'])

          expect(matcher.description).to eq(
            'validate that :attr is either ‹true› or ‹"dog"›',
          )
        end
      end

      context 'when there are three or more values' do
        it 'returns the correct string' do
          matcher = validate_inclusion_of(:attr).in_array([true, 'dog', 'cat'])

          expect(matcher.description).to eq(
            'validate that :attr is either ‹true›, ‹"dog"›, or ‹"cat"›',
          )
        end
      end
    end

    context 'given a range of values' do
      it 'returns the correct string' do
        matcher = validate_inclusion_of(:attr).in_range(1..10)

        expect(matcher.description).to eq(
          'validate that :attr lies inside the range ‹1› to ‹10›',
        )
      end
    end
  end

  def object_builder_class
    @_object_builder_class ||= Struct.new(
      :attribute,
      :object,
      :validation_options,
    )
  end

  def build_object_with_generic_attribute(
    model_name: nil,
    attribute_name: :attr,
    validation_options: nil,
    value: nil,
    **other_options
  )
    model = define_model_validating_inclusion(
      model_name: model_name,
      attribute_name: attribute_name,
      validation_options: validation_options,
      **other_options,
    )

    object = model.new
    object.__send__("#{attribute_name}=", value)

    object_builder_class.new(attribute_name, object, validation_options)
  end

  def define_model_validating_inclusion(
    model_name: nil,
    attribute_name: :attr,
    column_type: :string,
    column_options: {},
    validation_options: nil,
    custom_validation: nil,
    customize_model_class: -> (object) { }
  )
    column_options = { type: column_type, options: column_options }
    model_options = {
      model_name: model_name,
      attribute_name: attribute_name,
      column_options: column_options,
    }.compact

    define_simple_model(**model_options) do |model|
      if validation_options
        model.validates_inclusion_of(attribute_name, validation_options)
      end

      if custom_validation
        model.class_eval do
          define_method :custom_validation do
            custom_validation.call(self, attribute_name)
          end

          validate :custom_validation
        end
      end

      if customize_model_class
        model.instance_eval(&customize_model_class)
      end
    end
  end

  def build_object_allowing(values, validation_options: {}, **other_options)
    build_object(
      validation_options: validation_options.merge(in: values),
      **other_options,
    )
  end

  def expect_to_match(builder)
    matcher = validate_inclusion_of(builder.attribute)
    yield matcher if block_given?
    expect(builder.object).to(matcher)
  end

  def expect_not_to_match(builder)
    matcher = validate_inclusion_of(builder.attribute)
    yield matcher if block_given?
    expect(builder.object).not_to(matcher)
  end

  def expect_to_match_in_array(builder, array)
    expect_to_match(builder) do |matcher|
      matcher.in_array(array)
      yield matcher if block_given?
    end
  end

  def expect_not_to_match_in_array(builder, array)
    expect_not_to_match(builder) do |matcher|
      matcher.in_array(array)
      yield matcher if block_given?
    end
  end

  def expect_to_match_in_range(builder, range)
    expect_to_match(builder) do |matcher|
      matcher.in_range(range)
      yield matcher if block_given?
    end
  end

  def expect_not_to_match_in_range(builder, range)
    expect_not_to_match(builder) do |matcher|
      matcher.in_range(range)
      yield matcher if block_given?
    end
  end

  def expect_to_match_ensuring_range_and_messages(range, low_value, high_value)
    low_message = 'too low'
    high_message = 'too high'

    builder = build_object custom_validation: -> (object, attribute) {
      value = object.public_send(attribute)

      if value < low_value
        object.errors.add(attribute, low_message)
      elsif value > high_value
        object.errors.add(attribute, high_message)
      end
    }

    expect_to_match(builder) do |matcher|
      matcher.
        in_range(range).
        with_low_message(low_message).
        with_high_message(high_message)
    end
  end

  def validation_matcher_scenario_args
    super.deep_merge(matcher_name: :validate_inclusion_of)
  end
end
