module Shoulda
  module Matchers
    module ActiveRecord
      # The `define_enum_for` matcher is used to test that the `enum` macro has
      # been used to decorate an attribute with enum capabilities.
      #
      #     class Process < ActiveRecord::Base
      #       enum status: [:running, :stopped, :suspended]
      #
      #       alias_attribute :kind, :SomeLegacyField
      #
      #       enum kind: [:foo, :bar]
      #     end
      #
      #     # RSpec
      #     RSpec.describe Process, type: :model do
      #       it { should define_enum_for(:status) }
      #       it { should define_enum_for(:kind) }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status)
      #       should define_enum_for(:kind)
      #     end
      #
      # #### Qualifiers
      #
      # ##### with_values
      #
      # Use `with_values` to test that the attribute can only receive a certain
      # set of possible values.
      #
      #     class Process < ActiveRecord::Base
      #       enum status: [:running, :stopped, :suspended]
      #     end
      #
      #     # RSpec
      #     RSpec.describe Process, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_values([:running, :stopped, :suspended])
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_values([:running, :stopped, :suspended])
      #     end
      #
      # If the values backing your enum attribute are arbitrary instead of a
      # series of integers starting from 0, pass a hash to `with_values` instead
      # of an array:
      #
      #     class Process < ActiveRecord::Base
      #       enum status: {
      #         running: 0,
      #         stopped: 1,
      #         suspended: 3,
      #         other: 99
      #       }
      #     end
      #
      #     # RSpec
      #     RSpec.describe Process, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_values(running: 0, stopped: 1, suspended: 3, other: 99)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_values(running: 0, stopped: 1, suspended: 3, other: 99)
      #     end
      #
      # ##### backed_by_column_of_type
      #
      # Use `backed_by_column_of_type` when the column backing your column type
      # is a string instead of an integer:
      #
      #     class LoanApplication < ActiveRecord::Base
      #       enum status: {
      #         active: "active",
      #         pending: "pending",
      #         rejected: "rejected"
      #       }
      #     end
      #
      #     # RSpec
      #     RSpec.describe LoanApplication, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_values(
      #             active: "active",
      #             pending: "pending",
      #             rejected: "rejected"
      #           ).
      #           backed_by_column_of_type(:string)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class LoanApplicationTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_values(
      #           active: "active",
      #           pending: "pending",
      #           rejected: "rejected"
      #         ).
      #         backed_by_column_of_type(:string)
      #     end
      #
      ## ##### with_prefix
      #
      # Use `with_prefix` to test that the enum is defined with a `_prefix`
      # option (Rails 6+ only). Can take either a boolean or a symbol:
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], _prefix: :old
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_values([:open, :closed]).
      #           with_prefix(:old)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_values([:open, :closed]).
      #         with_prefix(:old)
      #     end
      #
      # ##### with_suffix
      #
      # Use `with_suffix` to test that the enum is defined with a `_suffix`
      # option (Rails 5 only). Can take either a boolean or a symbol:
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], _suffix: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_values([:open, :closed]).
      #           with_suffix
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_values([:open, :closed]).
      #         with_suffix
      #     end
      #
      # ##### without_scopes
      #
      # Use `without_scopes` to test that the enum is defined with
      # '_scopes: false' option (Rails 5 only). Can take either a boolean or a
      # symbol:
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], _scopes: false
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           without_scopes
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         without_scopes
      #     end
      #
      # ##### with_default
      #
      # Use `with_default` to test that the enum is defined with a
      # default value. A proc can also be passed, and will be called once each
      # time a new value is needed. (If using Time or Date, it's recommended to
      # freeze time or date to avoid flaky tests):
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], default: :closed
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           with_default(:closed)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         with_default(:closed)
      #     end
      #
      # ##### validating
      #
      # Use `validating` to test that the enum is being validated.
      # Can take a boolean value and an allowing_nil keyword argument:
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], validate: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           validating
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         validating
      #     end
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], validate: { allow_nil: true }
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           validating(allowing_nil: true)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         validating(allowing_nil: true)
      #     end
      #
      # ##### without_instance_methods
      #
      # Use `without_instance_methods` to exclude the check for instance methods.
      #
      #     class Issue < ActiveRecord::Base
      #       enum status: [:open, :closed], instance_methods: false
      #     end
      #
      #     # RSpec
      #     RSpec.describe Issue, type: :model do
      #       it do
      #         should define_enum_for(:status).
      #           without_instance_methods
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class ProcessTest < ActiveSupport::TestCase
      #       should define_enum_for(:status).
      #         without_instance_methods
      #     end
      #
      # @return [DefineEnumForMatcher]
      def define_enum_for(attribute_name)
        DefineEnumForMatcher.new(attribute_name)
      end

      # @private
      class DefineEnumForMatcher
        def initialize(attribute_name)
          @attribute_name = attribute_name
          @options = { expected_enum_values: [], scopes: true, instance_methods: true }
        end

        def description
          description = "#{simple_description} backed by "
          description << Shoulda::Matchers::Util.a_or_an(expected_column_type)

          if expected_enum_values.any?
            description << ' with values '
            description << Shoulda::Matchers::Util.inspect_value(
              expected_enum_values,
            )
          end

          if options[:prefix]
            description << ", prefix: #{options[:prefix].inspect}"
          end

          if options[:suffix]
            description << ", suffix: #{options[:suffix].inspect}"
          end

          description
        end

        def validating(value = true, allowing_nil: false)
          options[:validating] = value
          options[:allowing_nil] = allowing_nil
          self
        end

        def with_values(expected_enum_values)
          options[:expected_enum_values] = expected_enum_values
          self
        end

        def with_prefix(expected_prefix = true)
          options[:prefix] = expected_prefix
          self
        end

        def with_suffix(expected_suffix = true)
          options[:suffix] = expected_suffix
          self
        end

        def backed_by_column_of_type(expected_column_type)
          options[:expected_column_type] = expected_column_type
          self
        end

        def without_scopes
          options[:scopes] = false
          self
        end

        def without_instance_methods
          options[:instance_methods] = false
          self
        end

        def with_default(default_value)
          options[:default] = default_value
          self
        end

        def matches?(subject)
          @record = subject

          enum_defined? &&
            enum_values_match? &&
            column_type_matches? &&
            enum_value_methods_exist? &&
            scope_presence_matches? &&
            default_value_matches? &&
            validating_matches?
        end

        def failure_message
          message =
            if enum_defined?
              "Expected #{model} to #{expectation}. "
            else
              "Expected #{model} to #{expectation}, but "
            end

          message << "#{failure_message_continuation}."

          Shoulda::Matchers.word_wrap(message)
        end

        def failure_message_when_negated
          message = "Expected #{model} not to #{expectation}, but it did."
          Shoulda::Matchers.word_wrap(message)
        end

        private

        def validating_matches?
          return true if options[:validating].nil?

          validator = find_enum_validator

          if expected_validating? == !!validator
            if validator&.options&.dig(:allow_nil).present? == expected_allowing_nil?
              true
            else
              @failure_message_continuation =
                "However, #{attribute_name.inspect} is allowing nil values"
              false
            end
          else
            @failure_message_continuation =
              if expected_validating?
                "However, #{attribute_name.inspect} is not being validated"
              else
                "However, #{attribute_name.inspect} is being validated"
              end
            false
          end
        end

        attr_reader :attribute_name, :options, :record,
          :failure_message_continuation

        def expectation # rubocop:disable Metrics/MethodLength
          if enum_defined?
            expectation = "#{simple_description} backed by "
            expectation << Shoulda::Matchers::Util.a_or_an(expected_column_type)

            if expected_enum_values.any?
              expectation << ', mapping '
              expectation << presented_enum_mapping(
                normalized_expected_enum_values,
              )
            end

            if options[:default].present?
              expectation << ', with a default value of '
              expectation << Shoulda::Matchers::Util.inspect_value(expected_default_value)
            end

            if expected_validating?
              expectation << ', and being validated '
              expectation <<
                if expected_allowing_nil?
                  'allowing nil values'
                else
                  'not allowing nil values'
                end
            end

            if expected_prefix
              expectation <<
                if expected_suffix
                  ', '
                else
                  ' and '
                end

              expectation << 'prefixing accessor methods with '
              expectation << "#{expected_prefix}_".inspect
            end

            if expected_suffix
              expectation <<
                if expected_prefix
                  ', and '
                else
                  ' and '
                end

              expectation << 'suffixing accessor methods with '
              expectation << "_#{expected_suffix}".inspect
            end

            if exclude_scopes?
              expectation << ' with no scopes'
            end

            expectation
          else
            simple_description
          end
        end

        def simple_description
          "define :#{attribute_name} as an enum"
        end

        def presented_enum_mapping(enum_values)
          enum_values.
            map { |output_to_input|
              output_to_input.
                map(&Shoulda::Matchers::Util.method(:inspect_value)).
                join(' to ')
            }.
            to_sentence
        end

        def normalized_expected_enum_values
          to_hash(expected_enum_values)
        end

        def expected_enum_value_names
          to_array(expected_enum_values)
        end

        def expected_enum_values
          options[:expected_enum_values]
        end

        def normalized_actual_enum_values
          to_hash(actual_enum_values)
        end

        def actual_enum_values
          model.send(attribute_name.to_s.pluralize)
        end

        def enum_defined?
          if model.defined_enums.include?(attribute_name.to_s)
            true
          else
            @failure_message_continuation =
              "no such enum exists on #{model}"
            false
          end
        end

        def enum_values_match?
          passed =
            expected_enum_values.empty? ||
            normalized_actual_enum_values == normalized_expected_enum_values

          if passed
            true
          else
            @failure_message_continuation =
              "However, #{attribute_name.inspect} actually maps " +
              presented_enum_mapping(normalized_actual_enum_values)
            false
          end
        end

        def column_type_matches?
          if column.type == expected_column_type.to_sym
            true
          else
            @failure_message_continuation =
              "However, #{attribute_name.inspect} is "\
              "#{Shoulda::Matchers::Util.a_or_an(column.type)}"\
              ' column'
            false
          end
        end

        def expected_column_type
          options[:expected_column_type] || :integer
        end

        def column
          key = attribute_name.to_s
          column_name = model.attribute_alias(key) || key

          model.columns_hash[column_name]
        end

        def model
          record.class
        end

        def enum_value_methods_exist?
          if options[:instance_methods]
            return true if instance_methods_exist?

            message = missing_methods_message
            message << " (we can't tell which)" if [expected_prefix, expected_suffix].any?

            @failure_message_continuation = message

            false
          elsif instance_methods_exist?
            message = "#{attribute_name.inspect} does map to these values"
            message << ' with instance methods, but expected no instance methods'

            @failure_message_continuation = message

            false
          else
            true
          end
        end

        def scope_presence_matches?
          if exclude_scopes?
            if singleton_methods_exist?
              message = "#{attribute_name.inspect} does map to these values "
              message << 'but class scope methods were present'

              @failure_message_continuation = message

              false
            else
              true
            end
          elsif singleton_methods_exist?
            true
          else
            if enum_defined?
              message = 'But the class scope methods are not present'
            else
              message = missing_methods_message

              message << 'or the class scope methods are not present'
              message << " (we can't tell which)"
            end

            @failure_message_continuation = message

            false
          end
        end

        def missing_methods_message
          message = "#{attribute_name.inspect} does map to these "
          message << 'values, but the enum is '

          if expected_prefix
            if expected_suffix
              message << 'configured with either a different prefix or '
              message << 'suffix, or no prefix or suffix at all'
            else
              message << 'configured with either a different prefix or no '
              message << 'prefix at all'
            end
          elsif expected_suffix
            message << 'configured with either a different suffix or no '
            message << 'suffix at all'
          elsif expected_instance_methods?
            message << 'configured with no instance methods'
          else
            ''
          end
        end

        def default_value_matches?
          return true if options[:default].blank?

          if actual_default_value.nil?
            @failure_message_continuation = 'However, no default value was set'
            return false
          end

          if actual_default_value == expected_default_value
            true
          else
            String.new.tap do |message|
              message << 'However, the default value is '
              message << Shoulda::Matchers::Util.inspect_value(actual_default_value)
              @failure_message_continuation = message
            end
            false
          end
        end

        def expected_default_value
          options[:default].respond_to?(:call) ? options[:default].call : options[:default]
        end

        def actual_default_value
          attribute_schema = if model.respond_to?(:_default_attributes)
                               model._default_attributes[attribute_name.to_s]
                             else
                               model.attributes_to_define_after_schema_loads[attribute_name.to_s]
                             end

          if Kernel.const_defined?('ActiveModel::Attribute::UserProvidedDefault') &&
             attribute_schema.is_a?(::ActiveModel::Attribute::UserProvidedDefault)
            attribute_schema = attribute_schema.marshal_dump
          end

          value = case attribute_schema
                  in [_, { default: default_value } ]
                    default_value
                  in [_, default_value, *]
                    default_value
                  in [_, default_value]
                    default_value
                  end

          value.respond_to?(:call) ? value.call : value
        end

        def singleton_methods_exist?
          expected_singleton_methods.all? do |method|
            model.singleton_methods.include?(method)
          end
        end

        def instance_methods_exist?
          expected_instance_methods.all? do |method|
            record.methods.include?(method)
          end
        end

        def expected_singleton_methods
          expected_enum_value_names.map do |name|
            [expected_prefix, name, expected_suffix].
              select(&:present?).
              join('_').
              to_sym
          end
        end

        def expected_instance_methods
          methods = expected_enum_value_names.map do |name|
            [expected_prefix, name, expected_suffix].
              select(&:present?).
              join('_')
          end

          methods.flat_map do |m|
            ["#{m}?".to_sym, "#{m}!".to_sym]
          end
        end

        def expected_instance_methods?
          options[:instance_methods]
        end

        def expected_prefix
          if options.include?(:prefix)
            if options[:prefix] == true
              attribute_name
            else
              options[:prefix]
            end
          end
        end

        def expected_suffix
          if options.include?(:suffix)
            if options[:suffix] == true
              attribute_name
            else
              options[:suffix]
            end
          end
        end

        def expected_validating?
          options[:validating].present?
        end

        def expected_allowing_nil?
          options[:allowing_nil].present?
        end

        def find_enum_validator
          record.class.validators.detect do |validator|
            validator.kind == :inclusion &&
              validator.attributes.include?(attribute_name.to_s) &&
              validator.options[:in] == expected_enum_value_names
          end
        end

        def exclude_scopes?
          !options[:scopes]
        end

        def to_hash(value)
          if value.is_a?(Array)
            value.each_with_index.inject({}) do |hash, (item, index)|
              hash.merge!(item.to_s => index)
            end
          else
            value.stringify_keys
          end
        end

        def to_array(value)
          if value.is_a?(Array)
            value.map(&:to_s)
          else
            value.keys.map(&:to_s)
          end
        end
      end
    end
  end
end
