# frozen_string_literal: true

module Matchers
  def have_tag(selector, options = {}, &block)
    HaveTag.new(:html, selector, options, &block)
  end

  def have_node(selector, options = {}, &block)
    HaveTag.new(:xml, selector, options, &block)
  end

  class HaveTag
    attr_reader :options, :selector, :actual, :actual_count, :doc, :type

    def initialize(type, selector, options = {})
      @selector = selector
      @type = type

      @options = case options
                 when Hash
                   options
                 when Numeric
                   {count: options}
                 else
                   {text: options}
                 end
    end

    def doc_for(input)
      engine = type == :xml ? Nokogiri::XML : Nokogiri::HTML

      if input.respond_to?(:body)
        engine.parse(input.body.to_s)
      elsif input == Nokogiri::XML::Element
        input
      else
        engine.parse(input.to_s)
      end
    end

    def matches?(actual, &block)
      @actual = actual
      @doc = doc_for(actual)

      matches = doc.css(selector)

      return options[:count] == 0 if matches.empty?

      matches = filter_on_inner_text(matches) if options[:text]
      matches = filter_on_nested_expectations(matches, block) if block

      @actual_count = matches.size

      return false unless acceptable_count?(actual_count)

      !matches.empty?
    end

    def description
      "have tag #{selector.inspect} with #{options.inspect}"
    end

    def failure_message
      explanation = actual_count ? "but found #{actual_count}" : "but did not"

      "expected\n#{doc}\nto have #{failure_count_phrase} " \
        "#{failure_selector_phrase}, #{explanation}"
    end

    def failure_message_when_negated
      explanation = actual_count ? "but found #{actual_count}" : "but did"

      "expected\n#{doc}\nnot to have " \
        "#{failure_count_phrase} #{failure_selector_phrase}, #{explanation}"
    end

    private def filter_on_inner_text(elements)
      elements.select do |el|
        next(el.inner_text =~ options[:text]) if options[:text].is_a?(Regexp)

        el.inner_text.strip == options[:text]
      end
    end

    private def filter_on_nested_expectations(elements, block)
      elements.select do |el|
        block[el]
      rescue RSpec::Expectations::ExpectationNotMetError
        false
      else
        true
      end
    end

    private def acceptable_count?(count)
      return false if options[:count] && options[:count] != count
      return false if options[:minimum] && count < options[:minimum]
      return false if options[:maximum] && count > options[:maximum]

      true
    end

    private def failure_count_phrase
      if options[:count]
        "#{options[:count]} elements matching"
      elsif options[:minimum] || options[:maximum]
        count_explanations = []
        if options[:minimum]
          count_explanations << "at least #{options[:minimum]}"
        end
        if options[:maximum]
          count_explanations << "at most #{options[:maximum]}"
        end
        "#{count_explanations.join(' and ')} elements matching"
      else
        "an element matching"
      end
    end

    private def failure_selector_phrase
      phrase = selector.inspect
      phrase << (
        options[:text] ? " with inner text #{options[:text].inspect}" : ""
      )
    end
  end
end
