require "watir"

module Tapestry
  module_function

  NATIVE_QUALIFIERS = %i(visible).freeze

  def elements?
    @elements
  end

  def recognizes?(method)
    @elements.include? method.to_sym
  end

  def elements
    @elements = Watir::Container.instance_methods unless @elements
  end

  module Element
    # This iterator goes through the Watir container methods and
    # provides a method for each so that Watir-based element names
    # cane be defined on an interface definition, as part of an
    # element definition.
    Tapestry.elements.each do |element|
      define_method(element) do |*signature, &block|
        identifier, signature = parse_signature(signature)
        context = context_from_signature(signature, &block)
        define_element_accessor(identifier, signature, element, &context)
      end
    end

    private

    # A "signature" consists of a full element definition. For example:
    #
    #    text_field :username, id: 'username'
    #
    # The signature of this element definition is:
    #
    #    [:username, {:id=>"username"}]
    #
    # This is the identifier of the element (`username`) and the locator
    # provided for it. This method separates out the identifier and the
    # locator.
    def parse_signature(signature)
      [signature.shift, signature.shift]
    end

    # Returns the block or proc that serves as a context for an element
    # definition. Consider the following element definitions:
    #
    #    ul   :facts, id: 'fact-list'
    #    span :fact, -> { facts.span(class: 'site-item')}
    #
    # Here the second element definition provides a proc that contains a
    # context for another element definition. That leads to the following
    # construction being sent to the browser:
    #
    #    @browser.ul(id: 'fact-list').span(class: 'site-item')
    def context_from_signature(*signature, &block)
      if block_given?
        block
      else
        context = signature.shift
        context.is_a?(Proc) && signature.empty? ? context : nil
      end
    end

    # This method provides the means to get the aspects of an accessor
    # signature. The "aspects" refer to the locator information and any
    # qualifier information that was provided along with the locator.
    # This is important because the qualifier is not used to locate an
    # element but rather to put conditions on how the state of the
    # element is checked as it is being looked for.
    #
    # Note that "qualifiers" here refers to Watir boolean methods.
    def accessor_aspects(element, *signature)
      identifier = signature.shift
      locator_args = {}
      qualifier_args = {}
      gather_aspects(identifier, element, locator_args, qualifier_args)
      [locator_args, qualifier_args]
    end

    # This method is used to separate the two aspects of an accessor --
    # the locators and the qualifiers. Part of this process involves
    # querying the Watir driver library to determine what qualifiers
    # it handles natively. Consider the following:
    #
    #    select_list :accounts, id: 'accounts', selected: 'Select Option'
    #
    # Given that, this method will return with the following:
    #
    #    locator_args: {:id=>"accounts"}
    #    qualifier_args: {:selected=>"Select Option"}
    #
    # Consider this:
    #
    #    p :login_form, id: 'open', index: 0, visible: true
    #
    # Given that, this method will return with the following:
    #
    #    locator_args: {:id=>"open", :index=>0, :visible=>true}
    #    qualifier_args: {}
    #
    # Notice that the `visible` qualifier is part of the locator arguments
    # as opposed to being a qualifier argument, like `selected` was in the
    # previous example. This is because Watir 6.x handles the `visible`
    # qualifier natively. "Handling natively" means that when a qualifier
    # is part of the locator, Watir knows how to intrpret the qualifier
    # as a condition on the element, not as a way to locate the element.
    def gather_aspects(identifier, element, locator_args, qualifier_args)
      identifier.each_with_index do |hashes, index|
        next if hashes.nil? || hashes.is_a?(Proc)
        hashes.each do |k, v|
          methods = Watir.element_class_for(element).instance_methods
          if methods.include?(:"#{k}?") && !NATIVE_QUALIFIERS.include?(k)
            qualifier_args[k] = identifier[index][k]
          else
            locator_args[k] = v
          end
        end
      end
      [locator_args, qualifier_args]
    end

    # Defines an accessor method for an element that allows the "friendly
    # name" (identifier) of the element to be proxied to a Watir element
    # object that corresponds to the element type. When this identifier
    # is referenced, it generates an accessor method for that element
    # in the browser. Consider this element definition defined on a class
    # with an instance of `page`:
    #
    #    text_field :username, id: 'username'
    #
    # This allows:
    #
    #    page.username.set 'tester'
    #
    # So any element identifier can be called as if it were a method on
    # the interface (class) on which it is defined. Because the method
    # is proxied to Watir, you can use the full Watir API by calling
    # methods (like `set`, `click`, etc) on the element identifier.
    #
    # It is also possible to have an element definition like this:
    #
    #    text_field :password
    #
    # This would allow access like this:
    #
    #    page.username(id: 'username').set 'tester'
    #
    # This approach would lead to the *values variable having an array
    # like this: [{:id => 'username'}].
    #
    # A third approach would be to utilize one element definition within
    # the context of another. Consider the following element definitions:
    #
    #    article :practice, id: 'practice'
    #
    #    a :page_link do |text|
    #      practice.a(text: text)
    #    end
    #
    # This would allow access like this:
    #
    #    page.page_link('Drag and Drop').click
    #
    # This approach would lead to the *values variable having an array
    # like this: ["Drag and Drop"].
    def define_element_accessor(identifier, *signature, element, &block)
      locators, qualifiers = accessor_aspects(element, signature)
      define_method(identifier.to_s) do |*values|
        if block_given?
          instance_exec(*values, &block)
        else
          locators = values[0] if locators.empty?
          access_element(element, locators, qualifiers)
        end
      end
    end
  end

  module Locator
    private

    # This method is what actually calls the browser instance to find
    # an element. If there is an element definition like this:
    #
    #    text_field :username, id: 'username'
    #
    # This will become the following:
    #
    #    browser.text_field(id: 'username')
    #
    # Note that the `to_subtype` method is called, which allows for the
    # generic `element` to be expressed as the type of element, as opposed
    # to `text_field` or `select_list`. For example, an `element` may be
    # defined like this:
    #
    #    element :enable, id: 'enableForm'
    #
    # Which means it would look like this:
    #
    #    Watir::HTMLElement:0x1c8c9 selector={:id=>"enableForm"}
    #
    # Whereas getting the subtype would give you:
    #
    #    Watir::CheckBox:0x12f8b elector={element: (webdriver element)}
    #
    # Which is what you would get if the element definition were this:
    #
    #    checkbox :enable, id: 'enableForm'
    #
    # Using the subtype does get tricky for scripts that require the
    # built-in sychronization aspects and wait states of Watir.
    #
    # The approach being used in this method is necessary to allow actions
    # like `set`, which are not available on `element`, even though other
    # actions, like `click`, are. But if you use `element` for your element
    # definitions, and your script requires a series of actions where elements
    # may be delayed in appearing, you'll get an "unable to locate element"
    # message, along with a Watir::Exception::UnknownObjectException.
    def access_element(element, locators, _qualifiers)
      if element == "element".to_sym
        @browser.element(locators).to_subtype
      else
        @browser.__send__(element, locators)
      end
    end
  end
end