"""
parser.http.bsoupxpath module (imdb.parser.http package).

This module provides XPath support for BeautifulSoup.

Copyright 2008 H. Turgut Uyar <uyar@tekir.org>

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
"""

__author__ = 'H. Turgut Uyar <uyar@tekir.org>'
__docformat__ = 'restructuredtext'


import re
import string
import _bsoup as BeautifulSoup


# XPath related enumerations and constants

AXIS_ANCESTOR          = 'ancestor'
AXIS_ATTRIBUTE         = 'attribute'
AXIS_CHILD             = 'child'
AXIS_DESCENDANT        = 'descendant'
AXIS_FOLLOWING         = 'following'
AXIS_FOLLOWING_SIBLING = 'following-sibling'
AXIS_PRECEDING_SIBLING = 'preceding-sibling'

AXES = (AXIS_ANCESTOR, AXIS_ATTRIBUTE, AXIS_CHILD, AXIS_DESCENDANT,
        AXIS_FOLLOWING, AXIS_FOLLOWING_SIBLING, AXIS_PRECEDING_SIBLING)

XPATH_FUNCTIONS = ('starts-with', 'string-length', 'contains')


def tokenize_path(path):
    """Tokenize a location path into location steps. Return the list of steps.

    If two steps are separated by a double slash, the double slashes are part of
    the second step. If they are separated by only one slash, the slash is not
    included in any of the steps.
    """
    # form a list of tuples that mark the start and end positions of steps
    separators = []
    last_position = 0
    i = -1
    in_string = False
    while i < len(path) - 1:
        i = i + 1
        if path[i] == "'":
            in_string = not in_string
        if in_string:
            # slashes within strings are not step separators
            continue
        if path[i] == '/':
            if i > 0:
                separators.append((last_position, i))
            if (path[i+1] == '/'):
                last_position = i
                i = i + 1
            else:
                last_position = i + 1
    separators.append((last_position, len(path)))

    steps = []
    for start, end in separators:
        steps.append(path[start:end])
    return steps


class Path:
    """A location path.
    """

    def __init__(self, path, parse=True):
        self.path = path
        self.steps = []
        if parse:
            if (path[0] == '/') and (path[1] != '/'):
                # if not on the descendant axis, remove the leading slash
                path = path[1:]
            steps = tokenize_path(path)
            for step in steps:
                self.steps.append(PathStep(step))

    def apply(self, node):
        """Apply the path to a node. Return the resulting list of nodes.

        Apply the steps in the path sequentially by sending the output of each
        step as input to the next step.
        """
        # FIXME: this should return a node SET, not a node LIST
        # or at least a list with no duplicates
        if self.path[0] == '/':
            # for an absolute path, start from the root
            if not isinstance(node, BeautifulSoup.Tag) \
               or (node.name != '[document]'):
                node = node.findParent('[document]')
        nodes = [node]
        for step in self.steps:
            nodes = step.apply(nodes)
        return nodes


class PathStep:
    """A location step in a location path.
    """

    AXIS_PATTERN          = r"""(%s)::|@""" % '|'.join(AXES)
    NODE_TEST_PATTERN     = r"""\w+(\(\))?"""
    PREDICATE_PATTERN     = r"""\[(.*?)\]"""
    LOCATION_STEP_PATTERN = r"""(%s)?(%s)((%s)*)""" \
                          % (AXIS_PATTERN, NODE_TEST_PATTERN, PREDICATE_PATTERN)

    _re_location_step = re.compile(LOCATION_STEP_PATTERN)

    PREDICATE_NOT_PATTERN = r"""not\((.*?)\)"""
    PREDICATE_AXIS_PATTERN = r"""(%s)?(%s)(='(.*?)')?""" \
                           % (AXIS_PATTERN, NODE_TEST_PATTERN)
    PREDICATE_FUNCTION_PATTERN = r"""(%s)\(([^,]+(,\s*[^,]+)*)?\)(=(.*))?""" \
                               % '|'.join(XPATH_FUNCTIONS)

    _re_predicate_not = re.compile(PREDICATE_NOT_PATTERN)
    _re_predicate_axis = re.compile(PREDICATE_AXIS_PATTERN)
    _re_predicate_function = re.compile(PREDICATE_FUNCTION_PATTERN)

    def __init__(self, step):
        self.step = step
        if (step == '.') or (step == '..'):
            return

        if step[:2] == '//':
            default_axis = AXIS_DESCENDANT
            step = step[2:]
        else:
            default_axis = AXIS_CHILD

        step_match = self._re_location_step.match(step)

        # determine the axis
        axis = step_match.group(1)
        if axis is None:
            self.axis = default_axis
        elif axis == '@':
            self.axis = AXIS_ATTRIBUTE
        else:
            self.axis = step_match.group(2)

        self.soup_args = {}
        self.index = None

        self.node_test = step_match.group(3)
        if self.node_test == 'text()':
            self.soup_args['text'] = True
        else:
            self.soup_args['name'] = self.node_test

        self.checkers = []
        predicates = step_match.group(5)
        if predicates is not None:
            predicates = [p for p in predicates[1:-1].split('][') if p]
            for predicate in predicates:
                checker = self.__parse_predicate(predicate)
                if checker is not None:
                    self.checkers.append(checker)

    def __parse_predicate(self, predicate):
        """Parse the predicate. Return a callable that can be used to filter
        nodes. Update `self.soup_args` to take advantage of BeautifulSoup search
        features.
        """
        try:
            position = int(predicate)
            if self.axis == AXIS_DESCENDANT:
                return PredicateFilter('position', value=position)
            else:
                # use the search limit feature instead of a checker
                self.soup_args['limit'] = position
                self.index = position - 1
                return None
        except ValueError:
            pass

        if predicate == "last()":
            self.index = -1
            return None

        negate = self._re_predicate_not.match(predicate)
        if negate:
            predicate = negate.group(1)

        function_match = self._re_predicate_function.match(predicate)
        if function_match:
            name = function_match.group(1)
            arguments = function_match.group(2)
            value = function_match.group(4)
            if value is not None:
                value = function_match.group(5)
            return PredicateFilter(name, arguments, value)

        axis_match = self._re_predicate_axis.match(predicate)
        if axis_match:
            axis = axis_match.group(1)
            if axis is None:
                axis = AXIS_CHILD
            elif axis == '@':
                axis = AXIS_ATTRIBUTE
            if axis == AXIS_ATTRIBUTE:
                # use the attribute search feature instead of a checker
                attribute_name = axis_match.group(3)
                if axis_match.group(5) is not None:
                    attribute_value = axis_match.group(6)
                elif not negate:
                    attribute_value = True
                else:
                    attribute_value = None
                if not self.soup_args.has_key('attrs'):
                    self.soup_args['attrs'] = {}
                self.soup_args['attrs'][attribute_name] = attribute_value
                return None
            elif axis == AXIS_CHILD:
                node_test = axis_match.group(3)
                node_value = axis_match.group(6)
                return PredicateFilter('axis', node_test, value=node_value,
                                       negate=negate)

        raise NotImplementedError("This predicate is not implemented")

    def apply(self, nodes):
        """Apply the step to a list of nodes. Return the list of nodes for the
        next step.
        """
        if self.step == '.':
            return nodes
        elif self.step == '..':
            return [node.parent for node in nodes]

        result = []
        for node in nodes:
            if self.axis == AXIS_CHILD:
                found = node.findAll(recursive=False, **self.soup_args)
            elif self.axis == AXIS_DESCENDANT:
                found = node.findAll(recursive=True, **self.soup_args)
            elif self.axis == AXIS_ATTRIBUTE:
                try:
                    found = [node[self.node_test]]
                except KeyError:
                    found = []
            elif self.axis == AXIS_FOLLOWING_SIBLING:
                found = node.findNextSiblings(**self.soup_args)
            elif self.axis == AXIS_PRECEDING_SIBLING:
                # TODO: make sure that the result is reverse ordered
                found = node.findPreviousSiblings(**self.soup_args)
            elif self.axis == AXIS_FOLLOWING:
                # find the last descendant of this node
                last = node
                while (not isinstance(last, BeautifulSoup.NavigableString)) \
                      and (len(last.contents) > 0):
                    last = last.contents[-1]
                found = last.findAllNext(**self.soup_args)
            elif self.axis == AXIS_ANCESTOR:
                found = node.findParents(**self.soup_args)

            # this should only be active if there is a position predicate
            # and the axis is not 'descendant'
            if self.index is not None:
                if found:
                    if len(found) > self.index:
                        found = [found[self.index]]
                    else:
                        found = []

            if found:
                for checker in self.checkers:
                    found = filter(checker, found)
                result.extend(found)

        return result


class PredicateFilter:
    """A callable class for filtering nodes.
    """

    def __init__(self, name, arguments=None, value=None, negate=False):
        self.name = name
        self.arguments = arguments
        self.negate = negate

        if name == 'position':
            self.__filter = self.__position
            self.value = value
        elif name == 'axis':
            self.__filter = self.__axis
            self.node_test = arguments
            self.value = value
        elif name in ('starts-with', 'contains'):
            if name == 'starts-with':
                self.__filter = self.__starts_with
            else:
                self.__filter = self.__contains
            args = map(string.strip, arguments.split(','))
            if args[0][0] == '@':
                self.arguments = (True, args[0][1:], args[1][1:-1])
            else:
                self.arguments = (False, args[0], args[1][1:-1])
        elif name == 'string-length':
            self.__filter = self.__string_length
            args = map(string.strip, arguments.split(','))
            if args[0][0] == '@':
                self.arguments = (True, args[0][1:])
            else:
                self.arguments = (False, args[0])
            self.value = int(value)
        else:
            raise NotImplementedError("This XPath function is not implemented")

    def __call__(self, node):
        if self.negate:
            return not self.__filter(node)
        else:
            return self.__filter(node)

    def __position(self, node):
        if isinstance(node, BeautifulSoup.NavigableString):
            actual_position = len(node.findPreviousSiblings(text=True)) + 1
        else:
            actual_position = len(node.findPreviousSiblings(node.name)) + 1
        return actual_position == self.value

    def __axis(self, node):
        if self.node_test == 'text()':
            return node.string == self.value
        else:
            children = node.findAll(self.node_test, recursive=False)
            if len(children) > 0 and self.value is None:
                return True
            for child in children:
                if child.string == self.value:
                    return True
            return False

    def __starts_with(self, node):
        if self.arguments[0]:
            # this is an attribute
            attribute_name = self.arguments[1]
            if node.has_key(attribute_name):
                first = node[attribute_name]
                return first.startswith(self.arguments[2])
        elif self.arguments[1] == 'text()':
            first = node.contents and node.contents[0]
            if isinstance(first, BeautifulSoup.NavigableString):
                return first.startswith(self.arguments[2])
        return False

    def __contains(self, node):
        if self.arguments[0]:
            # this is an attribute
            attribute_name = self.arguments[1]
            if node.has_key(attribute_name):
                first = node[attribute_name]
                return self.arguments[2] in first
        elif self.arguments[1] == 'text()':
            first = node.contents and node.contents[0]
            if isinstance(first, BeautifulSoup.NavigableString):
                return self.arguments[2] in first
        return False

    def __string_length(self, node):
        if self.arguments[0]:
            # this is an attribute
            attribute_name = self.arguments[1]
            if node.has_key(attribute_name):
                value = node[attribute_name]
            else:
                value = None
        elif self.arguments[1] == 'text()':
            value = node.string
        if value is not None:
            return len(value) == self.value
        return False


_paths = {}
_steps = {}

def get_path(path):
    """Utility for eliminating repeated parsings of the same paths and steps.
    """
    if not _paths.has_key(path):
        p = Path(path, parse=False)
        steps = tokenize_path(path)
        for step in steps:
            if not _steps.has_key(step):
                _steps[step] = PathStep(step)
            p.steps.append(_steps[step])
        _paths[path] = p
    return _paths[path]
