#
# Copyright (c), 2016-2021, 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>
#
# mypy: ignore-errors
import os

from ..exceptions import XMLSchemaException, XMLSchemaValueError
from ..names import XSD_NAMESPACE, WSDL_NAMESPACE, SOAP_NAMESPACE, \
    SCHEMAS_DIR, XSD_ANY_TYPE, XSD_SCHEMA
from ..helpers import get_qname, local_name, get_extended_qname, get_prefixed_qname
from ..namespaces import NamespaceResourcesMap
from ..resources import fetch_resource
from ..documents import XmlDocument
from ..validators import XMLSchemaBase, XMLSchema10


# WSDL 1.1 global declarations
WSDL_IMPORT = '{%s}import' % WSDL_NAMESPACE
WSDL_TYPES = '{%s}types' % WSDL_NAMESPACE
WSDL_MESSAGE = '{%s}message' % WSDL_NAMESPACE
WSDL_PORT_TYPE = '{%s}portType' % WSDL_NAMESPACE
WSDL_BINDING = '{%s}binding' % WSDL_NAMESPACE
WSDL_SERVICE = '{%s}service' % WSDL_NAMESPACE

# Other WSDL tags
WSDL_PART = '{%s}part' % WSDL_NAMESPACE
WSDL_PORT = '{%s}port' % WSDL_NAMESPACE
WSDL_INPUT = '{%s}input' % WSDL_NAMESPACE
WSDL_OUTPUT = '{%s}output' % WSDL_NAMESPACE
WSDL_FAULT = '{%s}fault' % WSDL_NAMESPACE
WSDL_OPERATION = '{%s}operation' % WSDL_NAMESPACE

# WSDL SOAP Extensions
SOAP_BINDING = '{%s}binding' % SOAP_NAMESPACE
SOAP_OPERATION = '{%s}operation' % SOAP_NAMESPACE
SOAP_BODY = '{%s}body' % SOAP_NAMESPACE
SOAP_FAULT = '{%s}fault' % SOAP_NAMESPACE
SOAP_HEADER = '{%s}header' % SOAP_NAMESPACE
SOAP_HEADERFAULT = '{%s}headerfault' % SOAP_NAMESPACE
SOAP_ADDRESS = '{%s}address' % SOAP_NAMESPACE


class WsdlParseError(XMLSchemaException, SyntaxError):
    """An error during parsing of a WSDL document."""


class Wsdl11Maps:

    def __init__(self, wsdl_document):
        self.wsdl_document = wsdl_document
        self.imports = {}
        self.messages = {}
        self.port_types = {}
        self.bindings = {}
        self.services = {}

    def clear(self):
        self.imports.clear()
        self.messages.clear()
        self.port_types.clear()
        self.bindings.clear()
        self.services.clear()


class WsdlComponent:

    def __init__(self, elem, wsdl_document):
        self.elem = elem
        self.wsdl_document = wsdl_document
        self.name = get_qname(wsdl_document.target_namespace, elem.get('name'))

    def __repr__(self):
        return '%s(name=%r)' % (self.__class__.__name__, self.prefixed_name)

    def get(self, name):
        return self.elem.get(name)

    @property
    def attrib(self):
        return self.elem.attrib

    @property
    def local_name(self):
        if self.name:
            return local_name(self.name)

    @property
    def prefixed_name(self):
        if self.name:
            return get_prefixed_qname(self.name, self.wsdl_document.namespaces)

    def map_qname(self, qname):
        return get_prefixed_qname(qname, self.wsdl_document.namespaces)

    def unmap_qname(self, qname):
        return get_extended_qname(qname, self.wsdl_document.namespaces)

    def _parse_reference(self, elem, attribute_name):
        try:
            return self.unmap_qname(elem.attrib[attribute_name])
        except KeyError:
            return  # a missing attribute is already caught by XSD validator


class WsdlMessage(WsdlComponent):

    def __init__(self, elem, wsdl_document):
        super(WsdlMessage, self).__init__(elem, wsdl_document)
        self.parts = {}
        xsd_elements = wsdl_document.schema.maps.elements
        xsd_types = wsdl_document.schema.maps.types

        for child in elem.iterfind(WSDL_PART):
            part_name = child.get('name')
            if part_name is None:
                continue  # Ignore, missing name is already caught by XSD validator
            elif part_name in self.parts:
                msg = "duplicated part {!r} for {!r}"
                wsdl_document.parse_error(msg.format(part_name, self))

            try:
                element_attr = child.attrib['element']
            except KeyError:
                pass
            else:
                if 'type' in child.attrib:
                    msg = "ambiguous binding with both 'type' and 'element' attributes"
                    wsdl_document.parse_error(msg)

                element_name = get_extended_qname(element_attr, wsdl_document.namespaces)
                try:
                    self.parts[part_name] = xsd_elements[element_name]
                except KeyError:
                    self.parts[part_name] = xsd_types[XSD_ANY_TYPE]
                    msg = "missing schema element {!r}".format(element_name)
                    wsdl_document.parse_error(msg)

                continue  # pragma: no cover

            try:
                type_attr = child.attrib['type']
            except KeyError:
                msg = "missing both 'type' and 'element' attributes"
                wsdl_document.parse_error(msg)
            else:
                type_name = get_extended_qname(type_attr, wsdl_document.namespaces)
                try:
                    self.parts[part_name] = xsd_types[type_name]
                except KeyError:
                    self.parts[part_name] = xsd_types[XSD_ANY_TYPE]
                    msg = "missing schema type {!r}".format(type_name)
                    wsdl_document.parse_error(msg)


class WsdlPortType(WsdlComponent):

    def __init__(self, elem, wsdl_document):
        super(WsdlPortType, self).__init__(elem, wsdl_document)
        self.operations = {}

        for child in elem.iterfind(WSDL_OPERATION):
            operation_name = child.get('name')
            if operation_name is None:
                continue  # Ignore, missing name is already caught by XSD validator

            operation = WsdlOperation(child, wsdl_document)
            key = operation.key
            if key in self.operations:
                msg = "duplicated operation {!r} for {!r}"
                wsdl_document.parse_error(msg.format(operation_name, self))

            self.operations[key] = operation


class WsdlOperation(WsdlComponent):

    input = output = None
    soap_operation = None

    def __init__(self, elem, wsdl_document):
        super(WsdlOperation, self).__init__(elem, wsdl_document)
        self.faults = {}

        input_child = elem.find(WSDL_INPUT)
        if input_child is not None:
            self.input = WsdlInput(input_child, wsdl_document)

        output_child = elem.find(WSDL_OUTPUT)
        if output_child is not None:
            self.output = WsdlOutput(output_child, wsdl_document)

        for fault_child in elem.iterfind(WSDL_FAULT):
            fault = WsdlFault(fault_child, wsdl_document)
            if fault.name is None:
                continue
            elif fault.local_name in self.faults:
                msg = "duplicated fault {!r} for {!r}"
                wsdl_document.parse_error(msg.format(fault.local_name, self))
            self.faults[fault.local_name] = fault

        if input_child is not None and output_child is not None:
            children = self.elem[:]
            input_pos = children.index(input_child)
            output_pos = children.index(output_child)
            if input_pos < output_pos:
                self.transmission = 'request-response'
            else:
                self.transmission = 'solicit-response'

        elif input_child is not None:
            self.transmission = 'one-way'
        elif output_child is not None:
            self.transmission = 'notification'
        else:
            self.transmission = None

    @property
    def key(self):
        return self.local_name, \
            getattr(self.input, 'local_name', None), \
            getattr(self.output, 'local_name', None)

    @property
    def soap_action(self):
        """The SOAP operation's action URI if any, `None` otherwise."""
        if self.soap_operation is not None:
            return self.soap_operation.get('soapAction')

    @property
    def soap_style(self):
        """The SOAP operation's style if any, `None` otherwise."""
        if self.soap_operation is not None:
            style = self.soap_operation.get('style')
            return style if style in ('rpc', 'document') else 'document'


class WsdlMessageReference(WsdlComponent):
    message = None

    def __init__(self, elem, wsdl_document):
        super(WsdlMessageReference, self).__init__(elem, wsdl_document)
        message_name = self._parse_reference(elem, 'message')
        try:
            self.message = wsdl_document.maps.messages[message_name]
        except KeyError:
            if message_name:
                msg = "unknown message {!r} for {!r}"
                wsdl_document.parse_error(msg.format(message_name, self))


class WsdlInput(WsdlMessageReference):
    soap_headers = ()
    soap_body = None


class WsdlOutput(WsdlMessageReference):
    soap_headers = ()
    soap_body = None


class WsdlFault(WsdlMessageReference):
    soap_fault = None


class SoapParameter(WsdlComponent):

    @property
    def use(self):
        use = self.elem.get('use')
        return use if use in ('literal', 'encoded') else None

    @property
    def encoding_style(self):
        return self.elem.get('encodingStyle')

    @property
    def namespace(self):
        return self.elem.get('namespace', '')


class SoapBody(SoapParameter):
    """Class for soap:body bindings."""

    def __init__(self, elem, wsdl_document):
        super(SoapBody, self).__init__(elem, wsdl_document)
        self.parts = elem.get('parts', '').split()


class SoapFault(SoapParameter):
    """Class for soap:fault bindings."""


class SoapHeader(WsdlMessageReference, SoapParameter):
    """Class for soap:header bindings."""
    part = None

    def __init__(self, elem, wsdl_document):
        super(SoapHeader, self).__init__(elem, wsdl_document)
        if self.message is not None and 'part' in elem.attrib:
            try:
                self.part = self.message.parts[elem.attrib['part']]
            except KeyError:
                msg = "missing message part {!r}"
                wsdl_document.parse_error(msg.format(elem.attrib['part']))

        if elem.tag == SOAP_HEADER:
            self.faults = [SoapHeaderFault(e, wsdl_document)
                           for e in elem.iterfind(SOAP_HEADERFAULT)]


class SoapHeaderFault(SoapHeader):
    """Class for soap:headerfault bindings."""


class WsdlBinding(WsdlComponent):

    port_type = None
    """The wsdl:portType definition related to the binding instance."""

    soap_binding = None
    """The SOAP binding element if any, `None` otherwise."""

    def __init__(self, elem, wsdl_document):
        super(WsdlBinding, self).__init__(elem, wsdl_document)
        self.operations = {}

        if wsdl_document.soap_binding:
            self.soap_binding = elem.find(SOAP_BINDING)
            if self.soap_binding is None:
                msg = "missing soap:binding element for {!r}"
                wsdl_document.parse_error(msg.format(self))

        port_type_name = self._parse_reference(elem, 'type')
        try:
            self.port_type = wsdl_document.maps.port_types[port_type_name]
        except KeyError:
            msg = "missing port type {!r} for {!r}"
            wsdl_document.parse_error(msg.format(port_type_name, self))
            return  # pragma: no cover

        for op_child in elem.iterfind(WSDL_OPERATION):
            op_name = op_child.get('name')
            if op_name is None:
                continue  # Ignore, missing name is already caught by XSD validator

            input_child = op_child.find(WSDL_INPUT)
            input_name = None if input_child is None else input_child.get('name')
            output_child = op_child.find(WSDL_OUTPUT)
            output_name = None if output_child is None else output_child.get('name')

            key = op_name, input_name, output_name
            if key in self.operations:
                msg = "duplicated operation {!r} for {!r}"
                wsdl_document.parse_error(msg.format(op_name, self))

            try:
                operation = self.port_type.operations[key]
            except KeyError:
                msg = "operation {!r} not found for {!r}"
                wsdl_document.parse_error(msg.format(op_name, self))
                continue  # pragma: no cover
            else:
                self.operations[key] = operation

            if wsdl_document.soap_binding:
                operation.soap_operation = op_child.find(SOAP_OPERATION)

            if input_child is not None:
                for body_child in input_child.iterfind(SOAP_BODY):
                    operation.input.soap_body = SoapBody(body_child, wsdl_document)
                    break
                operation.input.soap_headers = [
                    SoapHeader(e, wsdl_document) for e in input_child.iterfind(SOAP_HEADER)
                ]

            if output_child is not None:
                for body_child in output_child.iterfind(SOAP_BODY):
                    operation.output.soap_body = SoapBody(body_child, wsdl_document)
                    break
                operation.output.soap_headers = [
                    SoapHeader(e, wsdl_document) for e in output_child.iterfind(SOAP_HEADER)
                ]

            for fault_child in op_child.iterfind(WSDL_FAULT):
                fault = WsdlFault(fault_child, wsdl_document)
                if fault.name and fault.local_name not in operation.faults:
                    msg = "missing fault {!r} in {!r}"
                    wsdl_document.parse_error(msg.format(fault.local_name, operation))

                for soap_fault_child in fault_child.iterfind(SOAP_FAULT):
                    fault = SoapFault(soap_fault_child, wsdl_document)
                    if fault.name:
                        try:
                            operation.faults[fault.local_name].soap_fault = fault
                        except KeyError:
                            msg = "missing fault {!r} in {!r}"
                            wsdl_document.parse_error(msg.format(fault.local_name, operation))

    @property
    def soap_transport(self):
        """The SOAP binding's transport URI if any, `None` otherwise."""
        if self.soap_binding is not None:
            return self.soap_binding.get('transport')

    @property
    def soap_style(self):
        """The SOAP binding's style if any, `None` otherwise."""
        if self.soap_binding is not None:
            style = self.soap_binding.get('style')
            return style if style in ('rpc', 'document') else 'document'


class WsdlPort(WsdlComponent):

    binding = None
    soap_location = None

    def __init__(self, elem, wsdl_document):
        super(WsdlPort, self).__init__(elem, wsdl_document)

        binding_name = self._parse_reference(elem, 'binding')
        try:
            self.binding = wsdl_document.maps.bindings[binding_name]
        except KeyError:
            if binding_name:
                msg = "unknown binding {!r} for {!r} output"
                wsdl_document.parse_error(msg.format(binding_name, self))

        if wsdl_document.soap_binding:
            for child in elem.iterfind(SOAP_ADDRESS):
                self.soap_location = child.get('location')
                break


class WsdlService(WsdlComponent):

    def __init__(self, elem, wsdl_document):
        super(WsdlService, self).__init__(elem, wsdl_document)
        self.ports = {}

        for port_child in elem.iterfind(WSDL_PORT):
            port = WsdlPort(port_child, wsdl_document)
            port_name = port.local_name

            if port_name is None:
                continue  # Ignore, missing name is already caught by XSD validator
            elif port_name in self.ports:
                msg = "duplicated port {!r} for {!r}"
                wsdl_document.parse_error(msg.format(port.prefixed_name, self))
            else:
                self.ports[port_name] = port


class Wsdl11Document(XmlDocument):
    """
    Class for WSDL 1.1 documents.

    :param source: a string containing XML data or a file path or an URL or a \
    file like object or an ElementTree or an Element.
    :param schema: additional schema for providing XSD types and elements to the \
    WSDL document. Can be a :class:`xmlschema.XMLSchema` instance or a file-like \
    object or a file path or a URL of a resource or a string containing the XSD schema.
    :param cls: class to use for building the schema instance (for default \
    :class:`xmlschema.XMLSchema10` is used).
    :param validation: the XSD validation mode to use for validating the XML document, \
    that can be 'strict' (default), 'lax' or 'skip'.
    :param maps: WSDL definitions shared maps.
    :param namespaces: is an optional mapping from namespace prefix to URI.
    :param locations: resource location hints, that can be a dictionary or a \
    sequence of couples (namespace URI, resource URL).
    :param base_url: the base URL for base :class:`xmlschema.XMLResource` initialization.
    :param allow: the security mode for base :class:`xmlschema.XMLResource` initialization.
    :param defuse: the defuse mode for base :class:`xmlschema.XMLResource` initialization.
    :param timeout: the timeout for base :class:`xmlschema.XMLResource` initialization.
    """
    target_namespace = ''
    soap_binding = False

    def __init__(self, source, schema=None, cls=None, validation='strict',
                 namespaces=None, maps=None, locations=None, base_url=None,
                 allow='all', defuse='remote', timeout=300):

        if maps is not None:
            self.maps = maps
            self.schema = maps.wsdl_document.schema
        else:
            if cls is None:
                cls = XMLSchema10

            if isinstance(schema, XMLSchemaBase):
                cls = schema.__class__
                global_maps = schema.maps
            elif schema is not None:
                global_maps = cls(schema).maps
            else:
                global_maps = None

            self.schema = cls(
                source=os.path.join(SCHEMAS_DIR, 'WSDL/wsdl.xsd'),
                global_maps=global_maps,
            )
            self.maps = Wsdl11Maps(self)

        if locations:
            self.locations = NamespaceResourcesMap(locations)
        else:
            self.locations = NamespaceResourcesMap()

        super(Wsdl11Document, self).__init__(
            source=source,
            schema=self.schema,
            validation=validation,
            namespaces=namespaces,
            locations=locations,
            base_url=base_url,
            allow=allow,
            defuse=defuse,
            timeout=timeout,
        )

    @property
    def imports(self):
        """WSDL 1.1 imports of XSD or WSDL additional resources."""
        return self.maps.imports

    @property
    def messages(self):
        """WSDL 1.1 messages."""
        return self.maps.messages

    @property
    def port_types(self):
        """WSDL 1.1 port types."""
        return self.maps.port_types

    @property
    def bindings(self):
        """WSDL 1.1 bindings."""
        return self.maps.bindings

    @property
    def services(self):
        """WSDL 1.1 services."""
        return self.maps.services

    def parse(self, source, lazy=False):
        if lazy:
            raise WsdlParseError("{!r} instance cannot be lazy".format(self.__class__))

        super(Wsdl11Document, self).parse(source, lazy)
        self.target_namespace = self._root.get('targetNamespace', '')
        self.soap_binding = SOAP_NAMESPACE in self.namespaces.values()

        if self.namespace == XSD_NAMESPACE:
            self.schema.__class__(self, global_maps=self.schema.maps, locations=self.locations)
            return

        if self is self.maps.wsdl_document:
            self.maps.clear()

        self._parse_imports()
        self._parse_types()
        self._parse_messages()
        self._parse_port_types()
        self._parse_bindings()
        self._parse_services()

    def parse_error(self, message):
        if self.validation == 'strict':
            raise WsdlParseError(message)
        elif self.validation == 'lax':
            self.errors.append(WsdlParseError(message))

    def _parse_types(self):
        path = '{}/{}'.format(WSDL_TYPES, XSD_SCHEMA)

        for child in self._root.iterfind(path):
            source = self.subresource(child)
            self.schema.__class__(source, global_maps=self.schema.maps)

    def _parse_messages(self):
        for child in self.iterfind(WSDL_MESSAGE):
            message = WsdlMessage(child, self)
            if message.name in self.maps.messages:
                self.parse_error("duplicated message {!r}".format(message.prefixed_name))
            else:
                self.maps.messages[message.name] = message

    def _parse_port_types(self):
        for child in self.iterfind(WSDL_PORT_TYPE):
            port_type = WsdlPortType(child, self)
            if port_type.name in self.maps.port_types:
                self.parse_error("duplicated port type {!r}".format(port_type.prefixed_name))
            else:
                self.maps.port_types[port_type.name] = port_type

    def _parse_bindings(self):
        for child in self.iterfind(WSDL_BINDING):
            binding = WsdlBinding(child, self)
            if binding.name in self.maps.bindings:
                self.parse_error("duplicated binding {!r}".format(binding.prefixed_name))
            else:
                self.maps.bindings[binding.name] = binding

    def _parse_services(self):
        for child in self.iterfind(WSDL_SERVICE):
            service = WsdlService(child, self)
            if service.name in self.maps.services:
                self.parse_error("duplicated service {!r}".format(service.prefixed_name))
            else:
                self.maps.services[service.name] = service

    def _parse_imports(self):
        namespace_imports = NamespaceResourcesMap(map(
            lambda x: (x.get('namespace', ''), x.get('location', '')),
            filter(lambda x: x.tag == WSDL_IMPORT, self.root)
        ))

        for namespace, locations in namespace_imports.items():
            locations = [url for url in locations if url]
            try:
                locations.extend(self.locations[namespace])
            except KeyError:
                pass

            import_error = None
            for url in locations:
                try:
                    self.import_namespace(namespace, url, self.base_url)
                except (OSError, IOError) as err:
                    if import_error is None:
                        import_error = err
                except SyntaxError as err:
                    msg = "cannot import namespace %r: %s." % (namespace, err)
                    self.parse_error(msg)
                except XMLSchemaValueError as err:
                    self.parse_error(err)
                else:
                    break
            else:
                if import_error is not None:
                    msg = "import of namespace {!r} from {!r} failed: {}."
                    self.parse_error(msg.format(namespace, locations, str(import_error)))
                self.maps.imports[namespace] = None

    def import_namespace(self, namespace, location, base_url=None):
        if namespace == self.target_namespace:
            msg = "namespace to import must be different from the " \
                  "'targetNamespace' of the WSDL document"
            raise XMLSchemaValueError(msg)

        elif namespace in self.maps.imports:
            return self.maps.imports[namespace]

        url = fetch_resource(location, base_url or self.base_url)
        wsdl_document = self.__class__(
            source=url,
            maps=self.maps,
            namespaces=self._namespaces,
            validation=self.validation,
            base_url=self.base_url,
            allow=self.allow,
            defuse=self.defuse,
            timeout=self.timeout,
        )

        if wsdl_document.target_namespace != namespace:
            msg = 'imported {!r} has an unmatched namespace {!r}'
            self.parse_error(msg.format(wsdl_document, namespace))

        self.maps.imports[namespace] = wsdl_document
        return wsdl_document
