#
# Copyright (c), 2018-2020, SISSA (International School for Advanced Studies).
# All rights reserved.
# This file is distributed under the terms of the MIT License.
# See the file 'LICENSE' in the root directory of the present
# distribution, or http://opensource.org/licenses/MIT.
#
# @author Davide Brunato <brunato@sissa.it>
#
"""
XPath 3.0 implementation

Refs:
  - https://www.w3.org/TR/2014/REC-xpath-30-20140408/
  - https://www.w3.org/TR/xpath-functions-30/
"""
import os
import re
import codecs
import math
import xml.etree.ElementTree as ElementTree
from copy import copy
from urllib.parse import urlsplit

from ..exceptions import ElementPathError
from ..helpers import XML_NEWLINES_PATTERN, is_xml_codepoint
from ..namespaces import XPATH_FUNCTIONS_NAMESPACE, XPATH_MATH_FUNCTIONS_NAMESPACE, \
    XSLT_XQUERY_SERIALIZATION_NAMESPACE
from ..xpath_nodes import etree_iterpath, is_xpath_node, is_element_node, \
    is_document_node, is_etree_element, is_schema_node, TypedElement, \
    TextNode, AttributeNode, TypedAttribute, NamespaceNode, XPathNode
from ..xpath_token import ValueToken, XPathFunction
from ..xpath_context import XPathSchemaContext
from ..xpath2 import XPath2Parser
from ..datatypes import NumericProxy, QName, Date10, DateTime10, Time
from ..regex import translate_pattern, RegexError

from .xpath30_formats import UNICODE_DIGIT_PATTERN, DECIMAL_DIGIT_PATTERN, \
    MODIFIER_PATTERN, int_to_roman, int_to_alphabetic, int_to_numeric, \
    int_to_words, parse_datetime_picture

# XSLT and XQuery Serialization parameters
SERIALIZATION_PARAMS = '{%s}serialization-parameters' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_OMIT_XML_DECLARATION = '{%s}omit-xml-declaration' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_USE_CHARACTER_MAPS = '{%s}use-character-maps' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_CHARACTER_MAP = '{%s}character-map' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_METHOD = '{%s}method' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_INDENT = '{%s}indent' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_VERSION = '{%s}version' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_CDATA = '{%s}cdata-section-elements' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_NO_INDENT = '{%s}suppress-indentation' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_STANDALONE = '{%s}standalone' % XSLT_XQUERY_SERIALIZATION_NAMESPACE
SER_PARAM_ITEM_SEPARATOR = '{%s}item-separator' % XSLT_XQUERY_SERIALIZATION_NAMESPACE


class XPath30Parser(XPath2Parser):
    """
    XPath 3.0 expression parser class. Accepts all XPath 2.0 options as keyword
    arguments, but the *strict* option is ignored because XPath 3.0+ has braced
    URI literals and the expanded name syntax is not compatible.

    :param args: the same positional arguments of class :class:`XPath2Parser`.
    :param decimal_formats: a mapping with statically known decimal formats.
    :param kwargs: the same keyword arguments of class :class:`XPath2Parser`.
    """
    version = '3.0'

    SYMBOLS = XPath2Parser.SYMBOLS | {
        'Q{',  # see BracedURILiteral rule
        '||',  # concat operator
        '!',   # Simple map operator

        # Math functions (trigonometric and exponential)
        'pi', 'exp', 'exp10', 'log', 'log10', 'pow', 'sqrt',
        'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',

        # Formatting functions
        'format-integer', 'format-number', 'format-dateTime',
        'format-date', 'format-time',

        # String functions that use regular expressions
        'analyze-string',

        # Functions and operators on nodes
        'path', 'has-children', 'innermost', 'outermost',

        # Functions and operators on sequences
        'head', 'tail', 'generate-id', 'uri-collection',
        'unparsed-text', 'unparsed-text-lines', 'unparsed-text-available',
        'environment-variable', 'available-environment-variables',

        # Parsing and serializing
        'parse-xml', 'parse-xml-fragment', 'serialize',

        # Higher-order functions
        'function-lookup', 'function-name', 'function-arity', '#', '?',
        'for-each', 'filter', 'fold-left', 'fold-right', 'for-each-pair',

        # Expressions and node type functions
        'function', 'let', ':=',  # 'namespace-node', 'switch',
    }

    DEFAULT_NAMESPACES = {
        'math': XPATH_MATH_FUNCTIONS_NAMESPACE, **XPath2Parser.DEFAULT_NAMESPACES
    }

    function_signatures = XPath2Parser.function_signatures.copy()

    def __init__(self, *args, decimal_formats=None, **kwargs):
        kwargs.pop('strict', None)
        super(XPath30Parser, self).__init__(*args, **kwargs)
        self.decimal_formats = decimal_formats if decimal_formats is not None else {}


##
# XPath 3.0 definitions
register = XPath30Parser.register
literal = XPath30Parser.literal
infix = XPath30Parser.infix
method = XPath30Parser.method
function = XPath30Parser.function

register(':=')


XPath30Parser.unregister('?')
register('?', bases=(ValueToken,))


@method('?')
def nud(self):
    return self


###
# Braced/expanded QName(s)
XPath30Parser.duplicate('{', 'Q{')
XPath30Parser.unregister('{')
XPath30Parser.unregister('}')
register('{')
register('}', bp=100)


XPath30Parser.unregister('(')


@method(register('(', lbp=80, rpb=80, label='expression'))
def nud(self):
    if self.parser.next_token.symbol != ')':
        self[:] = self.parser.expression(),
    self.parser.advance(')')
    return self


@method('(')
def led(self, left):
    if left.symbol == '(name)' or left.symbol == ':' and left[1].symbol == '(name)':
        raise self.error('XPST0017', 'unknown function {!r}'.format(left.value))
    if self.parser.next_token.symbol != ')':
        self[:] = left, self.parser.expression()
    else:
        self[:] = left,
    self.parser.advance(')')
    return self


@method('(')
def evaluate(self, context=None):
    if len(self) < 2:
        return self[0].evaluate(context) if self else []

    result = self[0].evaluate(context)
    if isinstance(result, list) and len(result) == 1:
        result = result[0]

    if not isinstance(result, XPathFunction):
        raise self.error('XPST0017', 'an XPath function expected, not {!r}'.format(type(result)))
    return result(context, self[1])


@method('(')
def select(self, context=None):
    if len(self) < 2:
        yield from self[0].select(context) if self else iter(())
    else:

        value = self[0].evaluate(context)
        if not isinstance(value, XPathFunction):
            raise self.error('XPST0017', 'an XPath function expected, not {!r}'.format(type(value)))
        result = value(context, self[1])
        if isinstance(result, list):
            yield from result
        else:
            yield result


@method(infix('||', bp=32))
def evaluate(self, context=None):
    return self.string_value(self.get_argument(context)) + \
        self.string_value(self.get_argument(context, index=1))


@method(infix('!', bp=72))
def select(self, context=None):
    if context is None:
        raise self.missing_context()

    for context.item in context.inner_focus_select(self[0]):
        for result in self[1].select(copy(context)):
            if not isinstance(result, (tuple, XPathNode)) and not hasattr(result, 'tag'):
                yield result
            elif isinstance(result, TypedElement):
                yield result
            elif isinstance(result, TypedAttribute):
                yield result
            else:
                yield result
                if isinstance(context, XPathSchemaContext):
                    self[1].add_xsd_type(result)


###
# 'let' expressions

@method(register('let', lbp=20, rbp=20, label='let expression'))
def nud(self):
    del self[:]
    if self.parser.next_token.symbol != '$':
        token = self.parser.symbol_table['(name)'](self.parser, self.symbol)
        return token.nud()

    while True:
        self.parser.next_token.expected('$')
        variable = self.parser.expression(5)
        self.append(variable)
        self.parser.advance(':=')
        expr = self.parser.expression(5)
        self.append(expr)
        if self.parser.next_token.symbol != ',':
            break
        self.parser.advance()

    self.parser.advance('return')
    self.append(self.parser.expression(5))
    return self


@method('let')
def select(self, context=None):
    if context is None:
        raise self.missing_context()

    context = copy(context)
    varnames = [self[k][0].value for k in range(0, len(self) - 1, 2)]
    values = [self[k].evaluate(copy(context)) for k in range(1, len(self) - 1, 2)]

    context.variables.update(x for x in zip(varnames, values))
    yield from self[-1].select(context)


###
# 'inline function' expression
@method(register('function', bp=90, label='inline function', bases=(XPathFunction,)))
def nud(self):
    if self.parser.next_token.symbol != '(':
        token = self.parser.symbol_table['(name)'](self.parser, self.symbol)
        return token.nud()

    self.parser.advance('(')
    self.sequence_types = []
    while self.parser.next_token.symbol != ')':
        self.parser.next_token.expected('$')
        param = self.parser.expression(5)
        self.append(param)
        if self.parser.next_token.symbol == 'as':
            self.parser.advance('as')
            token = self.parser.expression(5)
            sequence_type = token.source
            if not self.parser.is_sequence_type(sequence_type):
                raise token.error('XPST0003', "a sequence type expected")
            self.sequence_types.append(sequence_type)
        else:
            self.sequence_types.append('item()*')

        self.parser.next_token.expected(')', ',')
        if self.parser.next_token.symbol == ',':
            self.parser.advance()
            self.parser.next_token.unexpected(')')

    self.parser.advance(')')

    # Add function return type
    if self.parser.next_token.symbol != 'as':
        self.sequence_types.append('item()*')
    else:
        self.parser.advance('as')
        if self.parser.next_token.label not in ('kind test', 'sequence type'):
            self.parser.expected_name('(name)', ':')
        token = self.parser.expression(rbp=90)

        next_symbol = self.parser.next_token.symbol
        if token.symbol != 'empty-sequence' and next_symbol in {'?', '*', '+'}:
            self.parser.symbol_table[next_symbol](self.parser),  # Add nullary token
            self.parser.advance()
            sequence_type = token.source + next_symbol
        else:
            sequence_type = token.source

        if not self.parser.is_sequence_type(sequence_type):
            raise token.error('XPST0003', "a sequence type expected")
        self.sequence_types.append(sequence_type)

    self.parser.advance('{')
    self.expr = self.parser.expression()
    self.parser.advance('}')
    return self


@method('function')
def evaluate(self, context=None):
    if context is None:
        raise self.missing_context()
    return self.expr.evaluate(context)


###
# Mathematical functions
@method(function('pi', label='math function', nargs=0, sequence_types=('xs:double',)))
def evaluate(self, context=None):
    return math.pi


@method(function('exp', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        return math.exp(arg)


@method(function('exp10', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        return float(10 ** arg)


@method(function('log', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        return float('-inf') if not arg else float('nan') if arg <= -1 else math.log(arg)


@method(function('log10', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        return float('-inf') if not arg else float('nan') if arg <= -1 else math.log10(arg)


@method(function('pow', label='math function', nargs=2,
                 sequence_types=('xs:double?', 'numeric', 'xs:double?')))
def evaluate(self, context=None):
    x = self.get_argument(context, cls=NumericProxy)
    y = self.get_argument(context, index=1, required=True, cls=NumericProxy)
    if x is not None:
        if not x and y < 0:
            return math.copysign(float('inf'), x) if (y % 2) == 1 else float('inf')

        try:
            return float(x ** y)
        except TypeError:
            return float('nan')


@method(function('sqrt', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if arg < 0:
            return float('nan')
        return math.sqrt(arg)


@method(function('sin', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if math.isinf(arg):
            return float('nan')
        return math.sin(arg)


@method(function('cos', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if math.isinf(arg):
            return float('nan')
        return math.cos(arg)


@method(function('tan', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if math.isinf(arg):
            return float('nan')
        return math.tan(arg)


@method(function('asin', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if arg < -1 or arg > 1:
            return float('nan')
        return math.asin(arg)


@method(function('acos', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        if arg < -1 or arg > 1:
            return float('nan')
        return math.acos(arg)


@method(function('atan', label='math function', nargs=1,
                 sequence_types=('xs:double?', 'xs:double?')))
def evaluate(self, context=None):
    arg = self.get_argument(context, cls=NumericProxy)
    if arg is not None:
        return math.atan(arg)


@method(function('atan2', label='math function', nargs=2,
                 sequence_types=('xs:double', 'xs:double', 'xs:double')))
def evaluate(self, context=None):
    x = self.get_argument(context, cls=NumericProxy)
    y = self.get_argument(context, index=1, required=True, cls=NumericProxy)
    return math.atan2(x, y)


###
# Formatting functions
@method(function('format-integer', nargs=(2, 3),
                 sequence_types=('xs:integer?', 'xs:string', 'xs:string?', 'xs:string')))
def evaluate(self, context=None):
    value = self.get_argument(context, cls=NumericProxy)
    picture = self.get_argument(context, index=1, required=True, cls=str)
    lang = self.get_argument(context, index=2, cls=str)
    if value is None:
        return ''

    if ';' not in picture:
        fmt_token, fmt_modifier = picture, ''
    else:
        fmt_token, fmt_modifier = picture.rsplit(';', 1)

    if MODIFIER_PATTERN.match(fmt_modifier) is None:
        raise self.error('FODF1310')

    if not fmt_token:
        raise self.error('FODF1310')
    elif fmt_token in {'A', 'a', 'i', 'I', 'w', 'W', 'Ww'}:
        if fmt_token == 'a':
            result = int_to_alphabetic(value, lang)
        elif fmt_token == 'A':
            result = int_to_alphabetic(value, lang).upper()
        elif fmt_token == 'i':
            result = int_to_roman(value).lower()
        elif fmt_token == 'I':
            result = int_to_roman(value)
        elif fmt_token == 'w':
            return int_to_words(value, lang, fmt_modifier)
        elif fmt_token == 'W':
            return int_to_words(value, lang, fmt_modifier).upper()
        else:
            return int_to_words(value, lang, fmt_modifier).title()

    else:
        if UNICODE_DIGIT_PATTERN.search(fmt_token) is None:
            base_char = '1'
            for base_char in fmt_token:
                if base_char.isalpha():
                    break
            result = int_to_alphabetic(value, base_char)

        elif DECIMAL_DIGIT_PATTERN.match(fmt_token) is None:
            msg = 'picture argument has an invalid primary format token'
            raise self.error('FODF1310', msg)
        else:
            digits = UNICODE_DIGIT_PATTERN.findall(fmt_token)
            cp = ord(digits[0])
            if any((ord(ch) - cp) > 10 for ch in digits[1:]):
                msg = "picture argument mixes digits from different digit families"
                raise self.error('FODF1310', msg)
            elif fmt_token[0].isdigit():
                if '#' in fmt_token:
                    msg = 'picture argument has an invalid primary format token'
                    raise self.error('FODF1310', msg)
            elif fmt_token[0] != '#':
                raise self.error('FODF1310', "invalid grouping in picture argument")

            result = int_to_numeric(value, digits[0], fmt_token)

    if fmt_modifier.startswith('o'):
        if not value or abs(value) > 3:
            return '{}th'.format(result)
        elif abs(value) == 1:
            return '{}st'.format(result)
        elif abs(value) == 2:
            return '{}nd'.format(result)
        else:
            return '{}rd'.format(result)
    return result


# TODO
@method(function('format-number', nargs=(2, 3),
                 sequence_types=('numeric?', 'xs:string', 'xs:string?', 'xs:string')))
def evaluate(self, context=None):
    value = self.get_argument(context, cls=NumericProxy)
    # picture = self.get_argument(context, index=1, required=True, cls=str)
    if value is None:
        return ''


# TODO
@method(function('format-dateTime', nargs=(2, 5),
                 sequence_types=('xs:dateTime?', 'xs:string', 'xs:string?',
                                 'xs:string?', 'xs:string?', 'xs:string?')))
def evaluate(self, context=None):
    value = self.get_argument(context, cls=DateTime10)
    # picture = self.get_argument(context, index=1, required=True, cls=str)
    if value is None:
        return ''


# TODO
@method(function('format-date', nargs=(2, 5),
                 sequence_types=('xs:date?', 'xs:string', 'xs:string?',
                                 'xs:string?', 'xs:string?', 'xs:string?')))
def evaluate(self, context=None):
    value = self.get_argument(context, cls=Date10)
    # picture = self.get_argument(context, index=1, required=True, cls=str)
    if value is None:
        return None


# TODO
@method(function('format-time', nargs=(2, 5),
                 sequence_types=('xs:time?', 'xs:string', 'xs:string?',
                                 'xs:string?', 'xs:string?', 'xs:string?')))
def evaluate(self, context=None):
    value = self.get_argument(context, cls=Time)
    picture = self.get_argument(context, index=1, required=True, cls=str)
    # language = self.get_argument(context, index=2, cls=str)
    # calendar = self.get_argument(context, index=3, cls=str)
    # place = self.get_argument(context, index=4, cls=str)

    try:
        literals, markers = parse_datetime_picture(picture)
    except ElementPathError as err:
        err.token = self
        raise

    for value in markers:
        if value[1] in 'YMDdFWw':
            msg = 'Invalid time formatting component {!r}'.format(value[1])
            raise self.error('FOFD1350', msg)

    if value is None:
        return ''

    result = []
    for k in range(len(markers)):
        result.append(literals[k])
        component = markers[k][0]
        if component == 'f':
            pass  # TODO

    result.append(literals[-1])
    return ''.join(result)


###
# String functions that use regular expressions
@method(function('analyze-string', nargs=(2, 3),
                 sequence_types=('xs:string?', 'xs:string', 'xs:string',
                                 'element(fn:analyze-string-result)')))
def evaluate(self, context=None):
    input_string = self.get_argument(context, default='', cls=str)
    pattern = self.get_argument(context, 1, required=True, cls=str)
    flags = 0
    if len(self) > 2:
        for c in self.get_argument(context, 2, required=True, cls=str):
            if c in 'smix':
                flags |= getattr(re, c.upper())
            else:
                raise self.error('FORX0001', "Invalid regular expression flag %r" % c)

    try:
        python_pattern = translate_pattern(pattern, flags, self.parser.xsd_version)
        compiled_pattern = re.compile(python_pattern, flags=flags)
    except (re.error, RegexError) as err:
        msg = "Invalid regular expression: {}"
        raise self.error('FORX0002', msg.format(str(err))) from None
    except OverflowError as err:
        raise self.error('FORX0002', err) from None

    if compiled_pattern.match('') is not None:
        raise self.error('FORX0003', "pattern matches a zero-length string")

    level = 0
    escaped = False
    char_class = False
    group_levels = [0]
    for s in compiled_pattern.pattern:
        if escaped:
            escaped = False
        elif s == '\\':
            escaped = True
        elif char_class:
            if s == ']':
                char_class = False
        elif s == '[':
            char_class = True
        elif s == '(':
            group_levels.append(level)
            level += 1
        elif s == ')':
            level -= 1

    etree = ElementTree if context is None else context.etree
    lines = ['<analyze-string-result xmlns="{}">'.format(XPATH_FUNCTIONS_NAMESPACE)]
    k = 0

    while k < len(input_string):
        match = compiled_pattern.search(input_string, k)
        if match is None:
            lines.append('<non-match>{}</non-match>'.format(input_string[k:]))
            break
        elif not match.groups():
            start, stop = match.span()
            if start > k:
                lines.append('<non-match>{}</non-match>'.format(input_string[k:start]))
            lines.append('<match>{}</match>'.format(input_string[start:stop]))
            k = stop
        else:
            start, stop = match.span()
            if start > k:
                lines.append('<non-match>{}</non-match>'.format(input_string[k:start]))
                k = start

            match_items = []
            group_tmpl = '<group nr="{}">{}'
            empty_group_tmpl = '<group nr="{}"/>'
            unclosed_groups = 0

            for idx in range(1, compiled_pattern.groups + 1):
                start, stop = match.span(idx)
                if start < 0:
                    continue
                elif start > k:
                    if unclosed_groups:
                        for _ in range(unclosed_groups):
                            match_items.append('</group>')
                        unclosed_groups = 0

                    match_items.append(input_string[k:start])

                if start == stop:
                    if group_levels[idx] <= group_levels[idx - 1]:
                        for _ in range(unclosed_groups):
                            match_items.append('</group>')
                        unclosed_groups = 0
                    match_items.append(empty_group_tmpl.format(idx))
                    k = stop
                elif idx == compiled_pattern.groups:
                    k = stop
                    match_items.append(group_tmpl.format(idx, input_string[start:k]))
                    match_items.append('</group>')
                else:
                    next_start = match.span(idx + 1)[0]
                    if next_start < 0 or stop < next_start or stop == next_start \
                            and group_levels[idx + 1] <= group_levels[idx]:
                        k = stop
                        match_items.append(group_tmpl.format(idx, input_string[start:k]))
                        match_items.append('</group>')
                    else:
                        k = next_start
                        match_items.append(group_tmpl.format(idx, input_string[start:k]))
                        unclosed_groups += 1

            for _ in range(unclosed_groups):
                match_items.append('</group>')
            lines.append('<match>{}</match>'.format(''.join(match_items)))

    lines.append('</analyze-string-result>')
    return etree.XML(''.join(lines))


###
# Functions and operators on nodes
@method(function('path', nargs=(0, 1), sequence_types=('node()?', 'xs:string?')))
def evaluate(self, context=None):
    if context is None:
        raise self.missing_context()
    elif isinstance(context, XPathSchemaContext):
        return
    elif not self:
        if context.item is None:
            return '/'
        item = context.item
    else:
        item = self.get_argument(context)
        if item is None:
            return

    if is_document_node(item):
        return '/'
    elif isinstance(item, TypedElement):
        elem = item.elem
    elif is_etree_element(item):
        elem = item
    else:
        elem = self._elem

    try:
        root = context.root.getroot()
    except AttributeError:
        root = context.root
        path = 'Q{%s}root()' % XPATH_FUNCTIONS_NAMESPACE
    else:
        path = '/%s' % root.tag

    for e, path in etree_iterpath(root, path):
        if e is elem:
            return path


@method(function('has-children', nargs=(0, 1), sequence_types=('node()?', 'xs:boolean')))
def evaluate(self, context=None):
    if context is None:
        raise self.missing_context()
    elif not self:
        if context.item is None:
            return is_document_node(context.root)

        item = context.item
        if not is_xpath_node(item):
            raise self.error('XPTY0004', 'context item must be a node')
    else:
        item = self.get_argument(context)
        if item is None:
            return False
        elif not is_xpath_node(item):
            raise self.error('XPTY0004', 'argument must be a node')

    return is_document_node(item) or \
        is_element_node(item) and (len(item) > 0 or item.text is not None) or \
        isinstance(item, TypedElement) and (len(item.elem) > 0 or item.elem.text is not None)


@method(function('innermost', nargs=1, sequence_types=('node()*', 'node()*')))
def select(self, context=None):
    if context is None:
        raise self.missing_context()

    context = context.copy()
    nodes = [e for e in self[0].select(context)]
    if any(not is_xpath_node(x) for x in nodes):
        raise self.error('XPTY0004', 'argument must contain only nodes')

    ancestors = {x for context.item in nodes for x in context.iter_ancestors(axis='ancestor')}
    yield from context.iter_results([x for x in nodes if x not in ancestors])


@method(function('outermost', nargs=1, sequence_types=('node()*', 'node()*')))
def select(self, context=None):
    if context is None:
        raise self.missing_context()

    context = context.copy()
    nodes = {e for e in self[0].select(context)}
    if any(not is_xpath_node(x) for x in nodes):
        raise self.error('XPTY0004', 'argument must contain only nodes')

    yield from context.iter_results([
        context.item for context.item in nodes
        if all(x not in nodes for x in context.iter_ancestors(axis='ancestor'))
    ])


##
# Functions and operators on sequences
@method(function('head', nargs=1, sequence_types=('item()*', 'item()?')))
def evaluate(self, context=None):
    for item in self[0].select(context):
        return item


@method(function('tail', nargs=1, sequence_types=('item()*', 'item()?')))
def select(self, context=None):
    for k, item in enumerate(self[0].select(context)):
        if k:
            yield item


@method(function('generate-id', nargs=(0, 1), sequence_types=('node()?', 'xs:string')))
def evaluate(self, context=None):
    arg = self.get_argument(context, default_to_context=True)
    if arg is None:
        return ''
    elif not is_xpath_node(arg):
        if self:
            raise self.error('XPTY0004', "argument is not a node")
        raise self.error('XPTY0004', "context item is not a node")
    else:
        return 'ID-{}'.format(id(arg))


@method(function('uri-collection', nargs=(0, 1),
                 sequence_types=('xs:string?', 'xs:anyURI*')))
def evaluate(self, context=None):
    uri = self.get_argument(context)
    if context is None:
        raise self.missing_context()
    elif isinstance(context, XPathSchemaContext):
        return
    elif not self or uri is None:
        if context.default_resource_collection is None:
            raise self.error('FODC0002', 'no default resource collection has been defined')
        resource_collection = context.default_resource_collection
    else:
        uri = self.get_absolute_uri(uri)
        try:
            resource_collection = context.resource_collections[uri]
        except (KeyError, TypeError):
            url_parts = urlsplit(uri)
            if url_parts.scheme in ('', 'file') and \
                    not url_parts.path.startswith(':') and url_parts.path.endswith('/'):
                raise self.error('FODC0003', 'collection URI is a directory')
            raise self.error('FODC0002', '{!r} collection not found'.format(uri)) from None

    if not self.parser.match_sequence_type(resource_collection, 'xs:anyURI*'):
        raise self.wrong_sequence_type("Type does not match sequence type xs:anyURI*")

    return resource_collection


@method(function('unparsed-text', nargs=(1, 2),
                 sequence_types=('xs:string?', 'xs:string', 'xs:string?')))
@method(function('unparsed-text-lines', nargs=(1, 2),
                 sequence_types=('xs:string?', 'xs:string', 'xs:string*')))
def evaluate(self, context=None):
    from urllib.request import urlopen  # optional because it consumes ~4.3 MiB
    from urllib.error import URLError

    href = self.get_argument(context, cls=str)
    if href is None:
        return
    elif urlsplit(href).fragment:
        raise self.error('FOUT1170')

    if len(self) > 1:
        encoding = self.get_argument(context, index=1, required=True, cls=str)
    else:
        encoding = 'UTF-8'

    try:
        uri = self.get_absolute_uri(href)
    except ValueError:
        raise self.error('FOUT1170') from None

    try:
        codecs.lookup(encoding)
    except LookupError:
        raise self.error('FOUT1190') from None

    try:
        with urlopen(uri) as rp:
            obj = rp.read()
    except (ValueError, URLError) as err:
        message = str(err)
        if 'No such file' in message or \
                'unknown url type' in message or 'HTTP Error 404' in message:
            raise self.error('FOUT1170') from None
        raise self.error('FOUT1190') from None

    try:
        text = codecs.decode(obj, encoding)
    except UnicodeDecodeError:
        if len(self) > 1:
            raise self.error('FOUT1190') from None

        try:
            text = codecs.decode(obj, 'UTF-16')
        except UnicodeDecodeError:
            raise self.error('FOUT1190') from None

    if not all(is_xml_codepoint(ord(s)) for s in text):
        raise self.error('FOUT1190')

    text = text.lstrip('\ufeff')

    if self.symbol == 'unparsed-text-lines':
        lines = XML_NEWLINES_PATTERN.split(text)
        return lines[:-1] if lines[-1] == '' else lines

    return text


@method(function('unparsed-text-available', nargs=(1, 2),
                 sequence_types=('xs:string?', 'xs:string', 'xs:boolean')))
def evaluate(self, context=None):
    from urllib.request import urlopen  # optional because it consumes ~4.3 MiB
    from urllib.error import URLError

    href = self.get_argument(context, cls=str)
    if href is None:
        return False
    elif urlsplit(href).fragment:
        return False

    if len(self) > 1:
        encoding = self.get_argument(context, index=1, required=True, cls=str)
    else:
        encoding = 'UTF-8'

    try:
        uri = self.get_absolute_uri(href)
        codecs.lookup(encoding)
        with urlopen(uri) as rp:
            obj = rp.read()
    except (ValueError, URLError, LookupError):
        return False

    try:
        return all(is_xml_codepoint(ord(s)) for s in codecs.decode(obj, encoding))
    except UnicodeDecodeError:
        if len(self) > 1:
            return False

        try:
            return all(is_xml_codepoint(ord(s)) for s in codecs.decode(obj, 'UTF-16'))
        except UnicodeDecodeError:
            return False


@method(function('environment-variable', nargs=1,
                 sequence_types=('xs:string', 'xs:string?')))
def evaluate(self, context=None):
    name = self.get_argument(context, required=True, cls=str)
    if context is None:
        raise self.missing_context()
    elif not context.allow_environment:
        return
    else:
        return os.environ.get(name)


@method(function('available-environment-variables', nargs=0,
                 sequence_types=('xs:string*',)))
def evaluate(self, context=None):
    if context is None:
        raise self.missing_context()
    elif not context.allow_environment:
        return
    else:
        return list(os.environ)


###
# Parsing and serializing
@method(function('parse-xml', nargs=1,
                 sequence_types=('xs:string?', 'document-node(element(*))?')))
@method(function('parse-xml-fragment', nargs=1,
                 sequence_types=('xs:string?', 'document-node()?')))
def evaluate(self, context=None):
    # TODO: resolve relative entity references with static base URI
    arg = self.get_argument(context, cls=str)
    if arg is None:
        return

    etree = ElementTree if context is None else context.etree
    if self.symbol == 'parse-xml-fragment':
        # Wrap argument in a fake document because an
        # XML document can have only one root element
        arg = '<document>{}</document>'.format(arg)

    try:
        root = etree.XML(arg)
    except etree.ParseError:
        raise self.error('FODC0006')
    else:
        return etree.ElementTree(root)


@method(function('serialize', nargs=(1, 2), sequence_types=(
        'item()*', 'element(output:serialization-parameters)?', 'xs:string')))
def evaluate(self, context=None):
    # TODO full implementation of serialization with
    #  https://www.w3.org/TR/xpath-functions-30/#xslt-xquery-serialization-30

    params = self.get_argument(context, index=1) if len(self) == 2 else None
    if params is None:
        tmpl = '<output:serialization-parameters xmlns:output="{}"/>'
        params = ElementTree.XML(tmpl.format(XSLT_XQUERY_SERIALIZATION_NAMESPACE))
    elif not is_etree_element(params):
        pass
    elif params.tag != SERIALIZATION_PARAMS:
        raise self.error('XPTY0004', 'output:serialization-parameters tag expected')

    if context is None:
        etree = ElementTree
    else:
        etree = context.etree
        if context.namespaces:
            for pfx, uri in context.namespaces.items():
                etree.register_namespace(pfx, uri)
        else:
            for pfx, uri in self.parser.namespaces.items():
                etree.register_namespace(pfx, uri)

    item_separator = '\n'
    kwargs = {}
    character_map = {}
    if len(params):
        if len(params) > len({e.tag for e in params}):
            raise self.error('SEPM0019')

        for child in params:
            if child.tag == SER_PARAM_OMIT_XML_DECLARATION:
                value = child.get('value')
                if value not in {'yes', 'no'} or len(child.attrib) > 1:
                    raise self.error('SEPM0017')
                elif value == 'no':
                    kwargs['xml_declaration'] = True

            elif child.tag == SER_PARAM_USE_CHARACTER_MAPS:
                if len(child.attrib):
                    raise self.error('SEPM0017')

                for e in child:
                    if e.tag != SER_PARAM_CHARACTER_MAP:
                        raise self.error('SEPM0017')

                    try:
                        character = e.attrib['character']
                        if character in character_map:
                            msg = 'duplicate character {!r} in character map'
                            raise self.error('SEPM0018', msg.format(character))
                        elif len(character) != 1:
                            msg = 'invalid character {!r} in character map'
                            raise self.error('SEPM0017', msg.format(character))

                        character_map[character] = e.attrib['map-string']
                    except KeyError as key:
                        msg = "missing {} in character map"
                        raise self.error('SEPM0017', msg.format(key)) from None
                    else:
                        if len(e.attrib) > 2:
                            msg = "invalid attribute in character map"
                            raise self.error('SEPM0017', msg)

            elif child.tag == SER_PARAM_METHOD:
                value = child.get('value')
                if value not in {'html', 'xml', 'xhtml', 'text'} or len(child.attrib) > 1:
                    raise self.error('SEPM0017')
                kwargs['method'] = value if value != 'xhtml' else 'html'

            elif child.tag == SER_PARAM_INDENT:
                value = child.get('value')
                if value not in {'yes', 'no'} or len(child.attrib) > 1:
                    raise self.error('SEPM0017')

            elif child.tag == SER_PARAM_ITEM_SEPARATOR:
                try:
                    item_separator = child.attrib['value']
                except KeyError:
                    raise self.error('SEPM0017') from None

            # TODO params
            elif child.tag == SER_PARAM_CDATA:
                pass
            elif child.tag == SER_PARAM_NO_INDENT:
                pass
            elif child.tag == SER_PARAM_STANDALONE:
                pass

            elif child.tag.startswith(f'{{{XSLT_XQUERY_SERIALIZATION_NAMESPACE}'):
                raise self.error('SEPM0017')

    chunks = []
    for item in self[0].select(context):
        if is_document_node(item):
            item = item.getroot()
        elif isinstance(item, TypedElement):
            item = item.elem
        elif isinstance(item, (AttributeNode, TypedAttribute, NamespaceNode)):
            raise self.error('SENR0001')
        elif isinstance(item, TextNode):
            chunks.append(item.value)
            continue
        elif not is_etree_element(item):
            chunks.append(str(item))
            continue
        elif hasattr(item, 'xsd_version') or is_schema_node(item):
            continue  # XSD schema or schema node

        try:
            chunks.append(etree.tostring(item, encoding='utf-8', **kwargs).decode('utf-8'))
        except TypeError:
            chunks.append(etree.tostring(item, encoding='utf-8').decode('utf-8'))

    if not character_map:
        return item_separator.join(chunks)

    result = item_separator.join(chunks)
    for character, map_string in character_map.items():
        result = result.replace(character, map_string)
    return result


###
# Higher-order functions

@method(function('function-lookup', nargs=2,
                 sequence_types=('xs:QName', 'xs:integer', 'function(*)?')))
def evaluate(self, context=None):
    qname = self.get_argument(context, cls=QName)
    arity = self.get_argument(context, index=1, cls=int)

    # TODO: complete function signatures
    # if (qname, arity) not in self.parser.function_signatures:
    #    raise self.error('XPST0017')

    try:
        return self.parser.symbol_table[qname.local_name](self.parser, nargs=arity)
    except (KeyError, TypeError):
        raise self.error('XPST0017', "unknown function {}".format(qname.local_name))


@method(function('function-name', nargs=1, sequence_types=('function(*)', 'xs:QName?')))
def evaluate(self, context=None):
    if isinstance(self[0], XPathFunction):
        func = self[0]
    else:
        func = self.get_argument(context, cls=XPathFunction)

    return [] if func.name is None else func.name


@method(function('function-arity', nargs=1, sequence_types=('function(*)', 'xs:integer')))
def evaluate(self, context=None):
    if isinstance(self[0], XPathFunction):
        return self[0].arity

    func = self.get_argument(context, cls=XPathFunction)
    return func.arity


@method('#', bp=50)
def led(self, left):
    left.expected(':', '(name)', 'Q{')
    self[:] = left, self.parser.expression(rbp=90)
    self[1].expected('(integer)')
    return self


@method('#')
def evaluate(self, context=None):
    if self[0].symbol == ':':
        qname = QName(self[0][1].namespace, self[0].value)
    elif self[0].symbol == 'Q{':
        qname = QName(self[0][0].value, self[0][1].value)
    else:
        qname = QName(XPATH_FUNCTIONS_NAMESPACE, self[0].value)
    arity = self[1].value

    # TODO: complete function signatures
    # if (qname, arity) not in self.parser.function_signatures:
    #    raise self.error('XPST0017')

    try:
        return self.parser.symbol_table[qname.local_name](self.parser, nargs=arity)
    except (KeyError, TypeError):
        raise self.error('XPST0017', "unknown function {}".format(qname.local_name))


@method(function('for-each', nargs=2,
                 sequence_types=('item()*', 'function(item()) as item()*', 'item()*')))
def select(self, context=None):
    func = self[1][1] if self[1].symbol == ':' else self[1]
    if not isinstance(func, XPathFunction):
        func = self.get_argument(context, index=1, cls=XPathFunction)

    for item in self[0].select(copy(context)):
        result = func(context, argument_list=[item])
        if isinstance(result, list):
            yield from result
        else:
            yield result


@method(function('filter', nargs=2,
                 sequence_types=('item()*', 'function(item()) as xs:boolean', 'item()*')))
def select(self, context=None):
    func = self[1][1] if self[1].symbol == ':' else self[1]
    if not isinstance(func, XPathFunction):
        func = self.get_argument(context, index=1, cls=XPathFunction)

    for item in self[0].select(copy(context)):
        if self.boolean_value(func(context, argument_list=[item])):
            yield item


@method(function('fold-left', nargs=3,
                 sequence_types=('item()*', 'item()*',
                                 'function(item()*, item()) as item()*', 'item()*')))
def select(self, context=None):
    func = self[2][1] if self[2].symbol == ':' else self[2]
    if not isinstance(func, XPathFunction):
        func = self.get_argument(context, index=2, cls=XPathFunction)
    zero = self.get_argument(context, index=1)

    result = zero
    for item in self[0].select(copy(context)):
        result = func(context, argument_list=[result, item])

    if isinstance(result, list):
        yield from result
    else:
        yield result


@method(function('fold-right', nargs=3,
                 sequence_types=('item()*', 'item()*',
                                 'function(item()*, item()) as item()*', 'item()*')))
def select(self, context=None):
    func = self[2][1] if self[2].symbol == ':' else self[2]
    if not isinstance(func, XPathFunction):
        func = self.get_argument(context, index=2, cls=XPathFunction)
    zero = self.get_argument(context, index=1)

    result = zero
    sequence = [x for x in self[0].select(copy(context))]

    for item in reversed(sequence):
        result = func(context, argument_list=[item, result])

    if isinstance(result, list):
        yield from result
    else:
        yield result


@method(function('for-each-pair', nargs=3,
                 sequence_types=('item()*', 'item()*',
                                 'function(item(), item()) as item()*', 'item()*')))
def select(self, context=None):
    func = self[2][1] if self[2].symbol == ':' else self[2]
    if not isinstance(func, XPathFunction):
        func = self.get_argument(context, index=2, cls=XPathFunction)

    if not isinstance(func, XPathFunction):
        raise self.error('XPTY0004', "invalid type for 3rd argument {!r}".format(func))
    elif func.arity != 2:
        raise self.error('XPTY0004', "function arity of 3rd argument must be 2")

    for item1, item2 in zip(self[0].select(copy(context)), self[1].select(copy(context))):
        result = func(context, argument_list=[item1, item2])
        if isinstance(result, list):
            yield from result
        else:
            yield result


###
# Redefined or extended functions
XPath30Parser.unregister('string-join')


@method(function('string-join', nargs=(1, 2),
                 sequence_types=('xs:string*', 'xs:string', 'xs:string')))
def evaluate(self, context=None):
    items = [self.string_value(s) for s in self[0].select(context)]
    return self.get_argument(context, 1, default='', cls=str).join(items)
