_ = require 'lodash'

webdriver = require 'selenium-webdriver'

expect = require('./expect')()

require './object'

# The WebElement extension for finding subelts.
class Findable
  # Finds the elt with the given search selection chain.
  # Each selector is either a Protractor By object or a search
  # condition string. The search condition string can be CSS,
  # an id or an xpath, determined as follows:
  #
  # * If the string contains a '/' or equals '..', then the
  #   string is assumed to be an xpath.
  #
  # * Otherwise, if the string starts with '#', then the search
  #   is on the id following the hash.
  #
  # * Otherwise, if the search argument starts with '.', then
  #   the search is by CSS. 
  #
  # * Otherwise, the search is by tag name.
  #
  # For example,
  #
  #     page.find('.qi-billboard', 'h3', '..')
  #
  # returns the parent of the h3 elt within the .qi-billboard
  # elt on the page.
  #
  # The ElementFinder result is extended with a find function
  # for chaining, so the above example is equivalent to:
  #
  #     page.element(By.css('.qi-billboard'))
  #       .then (bb) ->
  #         bb.element(By.tagName('h3'))
  #       .then (h3) ->
  #         h3.element(By.xpath('..'))
  #
  # @param selectors the search conditions
  # @returns a promise which resolves to the target
  #   Findable, or null if no such WebElement exists
  find: (selectors...) ->
    @_find(selectors...).then (elt) ->
      if elt? then addFindableMixin(elt) else elt
  
  # @param selectors the search conditions
  # @returns a promise which resolves to the target
  #   ElementFinder, or null if no such elt exists
  _find: (selectors...) ->
    # @param current the Findable to search from, or this
    #   Page to search from the document root
    # @param locator the protractor By object
    # @returns the result of calling *elt* on the current
    #   object with the given locator
    next = (current, locator) ->
      current.element(locator)

    # Convert the selectors to locators.
    locators = (@_locatorFor(selector) for selector in selectors)
    # Chain the selects and return a promise which adds the
    # Findable methods to the result, if it exists.
    target = locators.reduce(next, this)
    target.isPresent().then (exists) ->
      if exists then target else null
  
  # Finds the elts with the given search selection.
  # The selectors are described in the find function.
  # Unlike the find function, findAll only accepts one
  # selector argument.
  #
  # Each Findable in the array result is extended
  # with a find function for chaining.
  #
  # @param selectors the search condition
  # @returns a promise which resolves to the target
  #   Findable array
  findAll: (selectors...) ->
    @_findAll(selectors...).then (target) ->
      target.map(addFindableMixin)
    
  # @param selectors the search condition
  # @returns a promise which resolves to the target
  #   WebElement array
  _findAll: (selectors...) ->
    # @param parent the Findable to search from, or
    #   this Page to search from the document root
    # @param locator the protractor By object
    # @returns the search result WebElement array
    next = (current, locator) ->
      current.all(locator)

    # Convert the selectors to locators.
    locators = (@_locatorFor(selector) for selector in selectors)
    # Chain the search.
    locators.reduce(next, this)

  # @param selectors the search condition
  # @returns a promise which resolves to this WebElement's visible
  #   text, or null if either:
  #   * the search result does not exist
  #   * the element is not displayed
  #   * the text content is empty
  text: (selectors...) ->
    @find(selectors...)
      .then (target) ->
        if target?
          target.isDisplayed().then (shown) ->
            if shown then target else null
        else
          null
      .then (target) ->
        if target? then target.getText() else null
      .then (text) ->
        if text? and text.length then text else null

  # Clicks on this element, captures the visited URL,
  # and returns.
  #
  # @returns the visited URL
  visit: ->
    # Navigates to the previous page, if necessary.
    #
    # @param prev_url the previous location
    # @returns the URL of the page navigated from
    restore = (prev_url) ->
      # The current URL.
      browser.getLocationAbsUrl().then (curr_url) ->
        # If the location changed, then navigate back to
        # the previous page.
        if curr_url == prev_url
          curr_url
        else
          browser.navigate().back().then ->
            # Resolve to the page navigated from.
            curr_url

    # Capture the current location.
    browser.getLocationAbsUrl().then (url) =>
      # Click the button and resolve to the home page.
      @click().then -> restore(url)
  
  # Finds a nested hyperlink. The hyperlink is the href
  # attribute of an anchor element (<a href=...></a>) contained
  # within this Findable's HTML.
  #
  # @param selectors the search condition
  # @returns a promise which resolves to the hyperlink URL
  hyperlink: (selectors...) ->
    selectors.push('a')
    @_find(selectors...).then (anchor) ->
      if anchor then anchor.getAttribute('href') else null

  # Finds all nested hyperlinks.
  # See the hyperlink method.
  #
  # @param selectors the search condition
  # @returns a promise which resolves to the hyperlink URLs
  hyperlinks: (selectors...) ->
    selectors.push('a')
    @_findAll(selectors...).then (anchors) ->
      anchors.map (anchor) ->
        anchor.getAttribute('href')

  # Finds the table WebElement for the given find() selector.
  # The return value is a promisr resolving to a Table object.
  # Each table promise resolves to an object {header, body},
  # where:
  # * header is a promise which resolves to an array of table
  #   heading text value promises
  # * body is a promise which resolves to an array of row promises
  #
  # Each row promise resolves to an array of column text promises.
  #
  # If the function argument is provided, then it is applied to
  # each table cell text value promise.
  #
  # @param bindings the table  {field: ng-bind expression}
  #   bindings
  # @param selectors the table elt find selectors
  # @returns a promise resolving to the table WebElement
  #   with the field properties, or null if the table
  #   does not exist
  findTable: (selectors...) ->
    finder = @_find(selectors...).then (table) ->
      # Add the Table methods to the elt.
      if table? then addTableMixin(table) else table

  # Finds the table WebElements for the given Page.findAll()
  # selector. The return value is an array of table promises.
  # Each table promise resolves to an object {header, body},
  # where:
  # * header is a promise which resolves to an array of table
  #   heading text value promises
  # * body is a promise which resolves to an array of row promises
  #
  # Each row promise resolves to an array of column text promises.
  #
  # If the function argument is provided, then it is applied to
  # each table cell text value promise.
  #
  # @param selector the Protractor CSS locator argument
  # @returns a promise resolving to the Table array
  findTables: (selectors...) ->
    @_findAll(selectors...).then (tables) ->
      # Add the Table prototype to each WebElement in the result.
      tables.map(addTableMixin)

  # @param selector the search condition
  # @returns the By locator object
  _locatorFor: (selector) ->
    # If the selector is a string, then make a By locator.
    # Otherwise, the selector is assumed to already be a
    # By locator.
    if _.isString(selector)
      # An xpath selector string has a slash or equals.
      # An id selector starts with # and doesn't have a space.
      # Anything else is assumed to be CSS.
      if selector == '..' or '/' in selector
        By.xpath(selector)
      else if selector[0] == '#' and not ' ' in selector
        By.id(selector[1..-1])
      else if selector.match('in ')
        By.repeater(selector)
      else if selector.match(/^\w[\w-]*$/)
        By.tagName(selector)
      else
        By.css(selector)
    else if selector?
      # Pass through a non-string selector.
      selector
    else
      throw new Error("The selector is not defined")


#
# Note - The Table class defined below is not defined
#   in it's own file because there is a cyclical Findable
#   dependency.
#

# The Table class represents a table WebElement with the
# following properties:
#
# * header - a promise resolving to the array of <th/>
#     WebElement text values
#
# * rows - a promise resolving to the two-dimensional
#     <tr/> x <td/> cell value array
#
# The header values can be either in a separate *thead*
# row or as the leading *th* element in each *tbody* row,
# e.g.:
#
#   <table id="t01">
#     <thead>
#       <tr>
#         <th>Name</th>
#         <th>Gender</th>
#       </tr>
#     </thead>
#     <tbody>
#       <tr>
#         <td>John</td>
#         <td>Male</td>
#       </tr>
#     </tbody>
#   </table>
#
# or:
#
#   <table id="t02">
#     <tbody>
#       <tr>
#         <th>Name</th>
#         <td>John</td>
#       </tr>
#       <tr>
#         <th>Gender</th>
#         <td>Male</td>
#       </tr>
#     </tbody>
#   </table>
#
# In the both cases, the Table *heading* resolves to
# ['Name', 'Gender']. In the first case, the Table
# *rows* resolves to [['John', 'Male]]. In the second
# case, the Table *rows* resolves to [['John'], ['Male]].
#
# If this table contains a <tbody/> subelement, then this
# property includes the rows in the first such body
# subelement. Otherwise, this methods returns all <tr/>
# rows contained in the table.
#
# Note - If there are multiple <tbody/> elements in the
#   table, only the rows of the first <tbody> are returned.
#
# Note - The rows of nested <table/> elements are returned
#   along with the parent rows.
#
# TODO - address the above two notes by adding a Table
#   subTables property for subtables and include only the
#   direct rows in a parent Table.
#
class Table extends Findable
  # Adds *field* properties to this table which resolve to the
  # the value given by the corresponding AngularJS binding
  # expression.
  #
  # @param bindings the {field: binding} object, where *binding*
  #   is the AngularJS expression to which the field element is
  #   bound
  # @returns this table WebElement
  addBindings: (bindings) ->
    for field, binding of bindings
      locator = By.binding(binding)
      this[field] = @find(locator).then (elt) ->
        if elt? then elt.text() else elt
    this

  # @returns a promise which resolves to the [[cell, ...], ...]
  #   row x column Finder array
  @property rows: ->
    # Chain the finder to preferably look for rows in a tbody tag,
    # otherwise look in the table itself.
    @find(By.tagName('tbody'))
      .then (body) ->
        # The parent element is the table child tbody, if it exists,
        # otherwise the table itself.
        if body? then body else this
      .then (parent) ->
        # Find the rows...
        parent.findAll(By.tagName('tr'))
      .then (rows) ->
        rows.map (row) ->
          # ...then find the cells...
          row.findAll(By.tagName('td'))
      .then (rows) ->
        webdriver.promise.all(rows)

  # @returns a promise which resolves to a header text array
  @property header: ->
    @findAll(By.tagName('th'))
      .then (headings) ->
        headings.map (heading) ->
          heading.text()
      .then (headings) ->
        webdriver.promise.all(headings)


#
# Findable utility functions.
#

# The following mix-ins return a new mix-in instance which
# delegates to the mixin argument, e.g.:
#   extended = addFindableMixin(result)
#   expect(extended).to.be.an.instanceOf(Findable)
#   expect(_.functionsIn(extended).to.include.members(_.functionsIn(result))
#

# @param mixin the mix-in class
# @param obj the source object
# @return an empty new mixin object which delegates to
#   the given object
addMixin = (mixin, obj) ->
  _.extend(new mixin(), obj)

# Adds Findable methods to the given WebElement.
addFindableMixin = _.partial(addMixin, Findable)

# Adds Table methods to the given WebElement.
addTableMixin = _.partial(addMixin, Table)

module.exports = Findable
