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
      #
      # @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 }
        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 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 matches?(subject)
          @record = subject

          enum_defined? &&
            enum_values_match? &&
            column_type_matches? &&
            enum_value_methods_exist? &&
            scope_presence_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

        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 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 instance_methods_exist?
            true
          else
            message = missing_methods_message

            message << " (we can't tell which)"

            @failure_message_continuation = message

            false
          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'
          else
            ''
          end
        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_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 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
