# -*- coding: utf-8 -*-

##############################################################################
#
# Copyright (c) 2011 Nexedi SARL and Contributors. All Rights Reserved.
#                    Arnaud Fontaine <arnaud.fontaine@nexedi.com>
#
# First version: ERP5Mechanize from Vincent Pelletier <vincent@nexedi.com>
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsability of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# garantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################

import logging

import sys
from urlparse import urljoin
from z3c.etestbrowser.browser import ExtendedTestBrowser
from zope.testbrowser.browser import onlyOne

def measurementMetaClass(prefix):
  """
  Prepare a meta class where the C{prefix} is used to select for which
  methods measurement methods will be added automatically.

  @param prefix:
  @type prefix: str
  @return: The measurement meta class corresponding to the prefix
  @rtype: type
  """
  class MeasurementMetaClass(type):
    """
    Meta class to define automatically C{time*InSecond} and
    C{time*InPystone} methods automatically according to given
    C{prefix}, and also to define C{lastRequestSeconds} and
    C{lastRequestPystones} on other classes besides of Browser.
    """
    def __new__(metacls, name, bases, dictionary):
      def applyMeasure(method):
        """
        Inner function to add the C{time*InSecond} and C{time*InPystone}
        methods to the dictionary of newly created class.

        For example, if the method name is C{submitSave} then
        C{timeSubmitSaveInSecond} and C{timeSubmitSaveInPystone} will
        be added to the newly created class.

        @param method: Instance method to be called
        @type method: function
        """
        # Upper the first character
        method_name_prefix = 'time' + method.func_name[0].upper() + \
            method.func_name[1:]

        def innerSecond(self, *args, **kwargs):
          """
          Call L{%(name)s} method and return the time it took in seconds.

          @param args: Positional arguments given to L{%(name)s}
          @param kwargs: Keyword arguments given to L{%(name)s}
          """
          method(self, *args, **kwargs)
          return self.lastRequestSeconds

        innerSecond.func_name = method_name_prefix + 'InSecond'
        innerSecond.__doc__ = innerSecond.__doc__ %  {'name': method.func_name}
        dictionary[innerSecond.func_name] = innerSecond

        def innerPystone(self, *args, **kwargs):
          """
          Call L{%(name)s} method and return the time it took in pystones.

          @param args: Positional arguments given to L{%(name)s}
          @param kwargs: Keyword arguments given to L{%(name)s}
          """
          method(self, *args, **kwargs)
          return self.lastRequestPystones

        innerPystone.func_name = method_name_prefix + 'InPystone'
        innerPystone.__doc__ = innerPystone.__doc__ % {'name': method.func_name}
        dictionary[innerPystone.func_name] = innerPystone

      # Create time*InSecond and time*InPystone methods only for the
      # methods prefixed by the given prefix
      for attribute_name, attribute in dictionary.items():
        if attribute_name.startswith(prefix) and callable(attribute):
          applyMeasure(attribute)

      # lastRequestSeconds and lastRequestPystones properties are only
      # defined on classes inheriting from zope.testbrowser.browser.Browser,
      # so create these properties for all other classes too
      if 'Browser' not in bases[0].__name__:
        time_method = lambda self: self.browser.lastRequestSeconds
        time_method.func_name = 'lastRequestSeconds'
        time_method.__doc__ = Browser.lastRequestSeconds.__doc__
        dictionary['lastRequestSeconds'] = property(time_method)

        time_method = lambda self: self.browser.lastRequestPystones
        time_method.func_name = 'lastRequestPystones'
        time_method.__doc__ = Browser.lastRequestPystones.__doc__
        dictionary['lastRequestPystones'] = property(time_method)

      return super(MeasurementMetaClass,
                   metacls).__new__(metacls, name, bases, dictionary)

  return MeasurementMetaClass

class Browser(ExtendedTestBrowser):
  """
  Implements mechanize tests specific to an ERP5 environment through
  U{ExtendedTestBrowser<http://pypi.python.org/pypi/z3c.etestbrowser>}
  (providing features to parse XML and access elements using XPATH)
  using U{zope.testbrowser<http://pypi.python.org/pypi/zope.testbrowser>}
  (providing benchmark and testing features on top of
  U{mechanize<http://wwwsearch.sourceforge.net/mechanize/>}).

  @todo:
   - getFormulatorFieldValue
  """
  __metaclass__ = measurementMetaClass(prefix='open')

  def __init__(self,
               base_url,
               erp5_site_id,
               username,
               password,
               log_filename=None,
               is_debug=False):
    """
    Create a browser object, allowing to log in right away with the
    given username and password. The base URL must contain an I{/} at
    the end.

    @param base_url: Base HTTP URL
    @type base_url: str
    @param erp5_site_id: ERP5 site name
    @type erp5_site_id: str
    @param username: Username to be used to log into ERP5
    @type username: str
    @param password: Password to be used to log into ERP5
    @param log_filename: Log filename (stdout if none given)
    @type log_filename: str
    @param is_debug: Enable or disable debugging (disable by default)
    @type is_debug: bool
    """
    # Meaningful to re-create the MainForm class every time the page
    # has been changed
    self._main_form_counter = -1
    self._main_form = None

    assert base_url[-1] == '/'

    self._base_url = base_url
    self._erp5_site_id = erp5_site_id
    self._erp5_base_url = urljoin(self._base_url, self._erp5_site_id) + '/'

    self._username = username
    self._password = password

    # Only display WARNING message if debugging is not enabled
    logging_level = level=(is_debug and logging.DEBUG or logging.WARNING)
    if log_filename:
      logging.basicConfig(filename=log_filename, level=logging_level)
    else:
      logging.basicConfig(stream=sys.stdout, level=logging_level)

    super(Browser, self).__init__()

    # Open login page, then login with the given username and password
    self.open('login_form')
    self.mainForm.submitLogin()

  def open(self, url_or_path=None, data=None):
    """
    Open a relative (to the ERP5 base URL) or absolute URL. If the
    given URL is not given, then it will open the home ERP5 page.

    @param url_or_path: Relative or absolute URL
    @type url_or_path: str
    """
    # In case url_or_path is an absolute URL, urljoin() will return
    # it, otherwise it is a relative path and will be concatenated to
    # ERP5 base URL
    absolute_url = urljoin(self._erp5_base_url, url_or_path)

    logging.info("Opening url: " + absolute_url)
    super(Browser, self).open(absolute_url, data)

  def getCookieValue(self, cookie_name, default=None):
    """
    Get the cookie value of the given cookie name.

    @param cookie_name: Name of the cookie
    @type cookie_name: str
    @param default: Fallback value if the cookie was not found
    @type default: str
    @return: Cookie value
    @rtype: str
    """
    for cookie in self.cookies:
      if name == cookie.name:
        return cookie.value

    return default

  @property
  def mainForm(self):
    """
    Get the ERP5 main form of the current page. ERP5 generally use
    only one form (whose C{id} is C{main_form}) for all the controls
    within a page. A Form instance is returned including helper
    methods specific to ERP5.

    @return: The main Form class instance
    @rtype: Form

    @raise LookupError: The main form could not be found.

    @todo: Perhaps the page could be parsed to generate a class with
           only appropriate methods, but that would certainly be an
           huge overhead for little benefit...

    @todo: Patch zope.testbrowser to allow the class to be given
           rather than duplicating the code
    """
    # If the page has not changed, no need to re-create a class, so
    # just return the main_form instance
    if self._main_form_counter == self._counter and self._main_form:
      return self._main_form

    self._main_form_counter = self._counter

    main_form = None
    for form in self.mech_browser.forms():
      if form.attrs.get('id') == 'main_form':
        main_form = form

    if not main_form:
      raise LookupError("Could not get 'main_form'")

    self.mech_browser.form = form
    self._main_form = ContextMainForm(self, form)
    return self._main_form

  def getContextLink(self, text=None, url=None, id=None, index=0):
    """
    Get an ERP5 link (see L{zope.testbrowser.interfaces.IBrowser}).

    @todo: Patch zope.testbrowser to allow the class to be given
           rather than duplicating the code
    """
    if id is not None:
      def predicate(link):
        return dict(link.attrs).get('id') == id
      args = {'predicate': predicate}
    else:
      from zope.testbrowser.browser import RegexType
      import re

      if isinstance(text, RegexType):
        text_regex = text
      elif text is not None:
        text_regex = re.compile(re.escape(text), re.DOTALL)
      else:
        text_regex = None

      if isinstance(url, RegexType):
        url_regex = url
      elif url is not None:
        url_regex = re.compile(re.escape(url), re.DOTALL)
      else:
        url_regex = None
      args = {'text_regex': text_regex, 'url_regex': url_regex}

    args['nr'] = index
    return ContextLink(self.mech_browser.find_link(**args), self)

  def getTransitionMessage(self):
    """
    Parses the current page and returns the value of the portal_status
    message.

    @return: The transition message
    @rtype: str

    @raise LookupError: Not found
    """
    try:
      return self.etree.xpath('//div[@id="transition_message"]')[0].text
    except IndexError:
      raise LookupError("Cannot find div with ID 'transition_message'")

  _listbox_table_xpath_str = '//table[contains(@class, "listbox-table")]'

  def getListboxLink(self, line_number, column_number, cell_element_index=1,
                     *args, **kwargs):
    """
    Follow the link at the given position, excluding any link whose
    class is hidden. In case there are several links within a cell,
    C{cell_element_index} allows to select which one to get (starting
    from 1).

    @param line_number: Line number of the link
    @type line_number: int
    @param column_number: Column number of the link
    @type column_number: int
    @param cell_element_index: Index of the link to be selected in the cell
    @type cell_element_index: int
    @param args: positional arguments given to C{getContextLink}
    @type args: list
    @param kwargs: keyword arguments given to C{getContextLink}
    @type kwargs: dict
    @return: C{Link} at the given line and column number
    @rtype: L{zope.testbrowser.interfaces.ILink}

    @raise LookupError: No link could be found at the given position
                        and cell indexes
    """
    xpath_str = '%s//tr[%d]//%s[%d]//a[not(contains(@class, "hidden"))][%d]' % \
        (self._listbox_table_xpath_str,
         line_number,
         line_number <= 2 and 'th' or 'td',
         column_number,
         cell_element_index)

    # xpath() method always return a list even if there is only one element
    element_list = self.etree.xpath(xpath_str)

    try:
      link_href = element_list[0].get('href')
    except (IndexError, AttributeError):
      link_href = None

    if not link_href:
      raise LookupError("Could not find link in listbox cell %dx%d (index=%d)" %\
                          (line_number, column_number, cell_element_index))

    return self.getContextLink(url=link_href, *args, **kwargs)

  def getListboxPosition(self,
                         text,
                         column_number=None,
                         line_number=None,
                         strict=False):
    """
    Returns the position number of the first line containing given
    text in given column or line number (starting from 1).

    @param text: Text to search
    @type text: str
    @param column_number: Look into all the cells of this column
    @type column_number: int
    @param line_number: Look into all the cells of this line
    @type line_number: int
    @param strict: Should given text matches exactly
    @type strict: bool
    @return: The cell position
    @rtype: int

    @raise LookupError: Not found
    """
    # Require either column_number or line_number to be given
    onlyOne([column_number, line_number], '"column_number" and "line_number"')

    cell_type = line_number <= 2 and 'th' or 'td'

    if column_number:
      column_or_line_xpath_str = '//tr//%s[%d]' % (cell_type, column_number)
    else:
      column_or_line_xpath_str = '//tr[%d]//%s' % (line_number, cell_type)

    # Get all cells in the column (if column_number is given) or line
    # (if line_number is given)
    cell_list = self.etree.xpath(self._listbox_table_xpath_str + \
                                   column_or_line_xpath_str)

    # Iterate over the cells list until one the children content
    # matches the expected text
    for position, cell in enumerate(cell_list):
      for child in cell.iterchildren():
        if not child.text:
          continue

        if (strict and child.text == text) or \
           (not strict and text in child.text):
          return position + 1

    raise LookupError("No matching cell with value '%s'" % text)

  def getRemainingActivityCounter(self):
    """
    Return the number of remaining activities

    @return: The number of remaining activities
    @rtype: int
    """
    self.open('portal_activities/countMessage')
    return self.contents and int(self.contents) or 0


from zope.testbrowser.browser import Form, ListControl

class LoginError(Exception):
  """
  Exception raised when login fails
  """
  pass

class MainForm(Form):
  """
  Class defining convenient methods for the main form of ERP5. All the
  methods specified are those always found in an ERP5 page in contrary
  to L{ContextMainForm}.
  """
  __metaclass__ = measurementMetaClass(prefix='submit')

  def submit(self, label=None, name=None, index=None, *args, **kwargs):
    """
    Overriden for logging purpose, and for specifying a default index
    to 0 if not set, thus avoiding AmbiguityError being raised (in
    ERP5 there may be several submit fields with the same name)
    """
    logging.debug("Submitting (name='%s', label='%s')" % (name, label))

    if label is None and name is None:
      super(MainForm, self).submit(label=label, name=name, *args, **kwargs)
    else:
      if index is None:
        index = 0

      super(MainForm, self).submit(label=label, name=name, index=index,
                                   *args, **kwargs)

  def submitSelect(self, select_name, submit_name, label=None, value=None):
    """
    Get the select control whose name attribute is C{select_name},
    then select the option control specified either by its C{label} or
    C{value} within that select control, and finally submit it using
    the submit control whose name attribute is C{submit_name}.

    The C{value} matches an option value if found at the end of the
    latter (excluding the query string), for example a search for
    I{/logout} will match I{/erp5/logout} and I{/erp5/logout?foo=bar}
    (if and only if C{value} contains no query string) but not
    I{/erp5/logout_bar}.

    Label value is searched as case-sensitive whole words within the
    labels for each item--that is, a search for I{Add} will match
    I{Add a contact} but not I{Address}.  A word is defined as one or
    more alphanumeric characters or the underline.

    @param select_name: Select control name
    @type select_name: str
    @param submit_name: Submit control name
    @type submit_name: str
    @param label: Label of the option control
    @type label: str
    @param value: Value of the option control
    @type value: str

    @raise LookupError: The select, option or submit control could not
                        be found
    """
    select_control = self.getControl(name=select_name)

    # zope.testbrowser checks for a whole word but it is also useful
    # to match the end of the option control value string because in
    # ERP5, the value could be URL (such as 'http://foo:81/erp5/logout')
    if value:
      for item in select_control.options:
        if '?' not in value:
          item = item.split('?')[0]

        if item.endswith(value):
          value = item
          break

    logging.debug("select_id='%s', label='%s', value='%s'" % \
                    (select_name, label, value))

    select_control.getControl(label=label, value=value).selected = True
    self.submit(name=submit_name)

  def submitLogin(self):
    """
    Log into ERP5 using the username and password provided in the browser.

    @raise LoginError: Login failed

    @todo: Use information sent back as headers rather than looking
           into the page content?
    """
    logging.debug("Logging in: username='%s', password='%s'" % \
                    (self.browser._username, self.browser._password))

    self.getControl(name='__ac_name').value = self.browser._username
    self.getControl(name='__ac_password').value = self.browser._password
    self.submit()

    if 'Logged In as' not in self.browser.contents:
      raise LoginError

  def submitSelectFavourite(self, label=None, value=None):
    """
    Select and submit a favourite, given either by its label (such as
    I{Log out}) or value (I{/logout}). See L{submitSelect}.
    """
    self.submitSelect('select_favorite', 'Base_doFavorite:method', label, value)

  def submitSelectModule(self, label=None, value=None):
    """
    Select and submit a module, given either by its label (such as
    I{Currencies}) or value (such as I{/glossary_module}). See
    L{submitSelect}.
    """
    self.submitSelect('select_module', 'Base_doModule:method', label, value)

  def submitSelectLanguage(self, label=None, value=None):
    """
    Select and submit a language, given either by its label (such as
    I{English}) or value (such as I{en}). See L{submitSelect}.
    """
    self.submitSelect('select_language', 'Base_doLanguage:method', label, value)

  def submitSearch(self, search_text):
    """
    Fill search field with C{search_text} and submit it.

    @param search_text: Text to search
    @type search_text: str
    """
    self.getControl('field_your_search_text').value = search_text
    self.submit(name='ERP5Site_viewQuickSearchResultList:method')

  def submitLogout(self):
    """
    Perform logout.
    """
    self.submitFavourite('select_favorite', 'Base_doFavorite:method',
                         name='logout')

class ContextMainForm(MainForm):
  """
  Class defining context-dependent convenient methods for the main
  form of ERP5.

  @todo:
   - doListboxAction
   - doContextListMode
   - doContextSearch
   - doContextSort
   - doContextConfigure
   - doContextButton
   - doContextReport
   - doContextExchange
  """
  def submitJump(self, label=None, value=None):
    """
    Select and submit a jump, given either by its label (such as
    I{Queries}) or value (such as
    I{/person_module/Base_jumpToRelatedObject?portal_type=Foo}). See
    L{submitSelect}.
    """
    self.submitSelect('select_jump', 'Base_doJump:method', label, value)

  def submitAction(self, label=None, value=None):
    """
    Select and submit an action, given either by its label (such as
    I{Add Person}) or value (such as I{add} and I{add Person}). See
    L{submitSelect}.
    """
    self.submitSelect('select_action', 'Base_doAction:method', label, value)

  def submitCut(self):
    """
    Cut the previously selected objects.
    """
    self.submit(name='Folder_cut:method')

  def submitCopy(self):
    """
    Copy the previously selected objects.
    """
    self.submit(name='Folder_copy:method')

  def submitPaste(self):
    """
    Paste the previously selected objects.
    """
    self.submit(name='Folder_paste:method')

  def submitPrint(self):
    """
    Print the previously selected objects.
    """
    self.submit(name='Folder_print:method')

  def submitNew(self):
    """
    Create a new object.
    """
    self.submit(name='Folder_create:method')

  def submitDelete(self):
    """
    Delete the previously selected objects.
    """
    self.submit(name='Folder_deleteObjectList:method')

  def submitSave(self):
    """
    Save the previously selected objects.
    """
    self.submit(name='Base_edit:method')

  def submitShow(self):
    """
    Show the previously selected objects.
    """
    self.submit(name='Folder_show:method')

  def submitFilter(self):
    """
    Filter the objects.
    """
    self.submit(name='Folder_filter:method')

  def submitSelectWorkflow(self, label=None, value=None,
                           script_id='viewWorkflowActionDialog'):
    """
    Select and submit a workflow action, given either by its label
    (such as I{Create User}) or value (such as I{create_user_action}
    in I{/Person_viewCreateUserActionDialog?workflow_action=create_user_action},
    with C{script_id=Person_viewCreateUserActionDialog}). See L{submitSelect}.

    When validating an object, L{submitDialogConfirm} allows to
    perform the validation required on the next page.

    @param script_id: Script identifier
    @type script_id: str
    """
    try:
      self.submitSelect('select_action', 'Base_doAction:method', label,
                        value and '%s?workflow_action=%s' % (script_id, value))

    except LookupError:
      self.submitSelect('select_action', 'Base_doAction:method', label,
                        value and '%s?field_my_workflow_action=%s' % (script_id,
                                                                      value))

  def submitDialogCancel(self):
    """
    Cancel the dialog action. A dialog is showed when validating a
    workflow or deleting an object for example.
    """
    self.submit(name='Base_cancel:method')

  def submitDialogConfirm(self):
    """
    Confirm the dialog action. A dialog is showed when validating a
    workflow or deleting an object for example.

    @todo: Specifying index is kind of ugly (there is C{dummy} field
           with the same name though)
    """
    self.submit(name='Base_callDialogMethod:method')

  def getListboxControl(self, line_number, column_number, cell_element_index=1,
                        *args, **kwargs):
    """
    Get the control located at line and column numbers (both starting
    from 1), excluding hidden control and those whose class is hidden
    too. The position of a cell from a column or line number can be
    obtained through calling
    L{erp5.utils.test_browser.browser.Browser.getListboxPosition}.

    Also, there may be several elements within a cell, thus
    C{cell_element_index} allows to select which one to get (starting
    from 1).

    @param line_number: Line number of the field
    @type line_number: int
    @param column_number: Column number of the field
    @type column_number: int
    @param cell_element_index: Index of the control to be selected in the cell
    @type cell_element_index: int
    @param args: positional arguments given to the parent C{getControl}
    @type args: list
    @param kwargs: keyword arguments given to the parent C{getControl}
    @type kwargs: dict
    @return: The control found at the given line and column numbers
    @rtype: L{zope.testbrowser.interfaces.IControl}

    @raise LookupError: No control could be found at the given
                        position and cell indexes
    """
    xpath_str = '%s//tr[%d]//%s[%d]/*[not(@type="hidden") and ' \
        'not(contains(@class, "hidden"))][%d]' % \
        (self.browser._listbox_table_xpath_str,
         line_number,
         (line_number <= 2 and u'th' or u'td'),
         column_number,
         cell_element_index)

    # xpath() method always return a list even if there is only one element
    element_list = self.browser.etree.xpath(xpath_str)

    try:
      input_element = element_list[0]
      input_name = input_element.get('name')
    except (IndexError, AttributeError):
      input_element = input_name = None

    if input_element is None or not input_name:
      raise LookupError("Could not find control in listbox cell %dx%d (index=%d)" %\
                          (line_number, column_number, cell_element_index))

    control = self.getControl(name=input_element.get('name'), *args, **kwargs)

    # If this is a list control (radio button, checkbox or select
    # control), then get the item from its value
    if isinstance(control, ListControl):
      control = control.getControl(value=input_element.get('value'))

    return control


from zope.testbrowser.browser import Link

class ContextLink(Link):
  """
  Class defining convenient methods for context-dependent links of
  ERP5.
  """
  __metaclass__ = measurementMetaClass(prefix='click')

  def clickFirst(self):
    """
    Go to the first page.
    """
    self.getLink(url='/viewFirst')

  def clickPrevious(self):
    """
    Go to the previous page.
    """
    self.getLink(url='/viewPrevious').click()

  def clickNext(self):
    """
    Go to the next page.
    """
    self.getLink(url='/viewNext').click()

  def clickLast(self):
    """
    Go to the last page.
    """
    self.getLink(url='/viewLast').click()