# wsdl_importer.py
#
# Copyright 2025 Pavel Baksy
#
# 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 3 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, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

# WSDL 1.1
_WSDL   = 'http://schemas.xmlsoap.org/wsdl/'
_SOAP11 = 'http://schemas.xmlsoap.org/wsdl/soap/'
_SOAP12 = 'http://schemas.xmlsoap.org/wsdl/soap12/'
_ENV11  = 'http://schemas.xmlsoap.org/soap/envelope/'
_ENV12  = 'http://www.w3.org/2003/05/soap-envelope'

# WSDL 2.0
_WSDL2      = 'http://www.w3.org/ns/wsdl'
_WSDL2_SOAP = 'http://www.w3.org/ns/wsdl/soap'

# XML Schema
_XS = 'http://www.w3.org/2001/XMLSchema'

# XSD simple-type → human hint
_XS_HINTS: Dict[str, str] = {
    'string': 'string', 'normalizedString': 'string', 'token': 'string',
    'int': 'int', 'integer': 'int', 'nonNegativeInteger': 'int',
    'positiveInteger': 'int', 'negativeInteger': 'int',
    'short': 'int', 'byte': 'int', 'unsignedByte': 'int',
    'unsignedInt': 'int', 'unsignedShort': 'int',
    'long': 'long', 'unsignedLong': 'long',
    'boolean': 'boolean',
    'float': 'float', 'double': 'float', 'decimal': 'decimal',
    'dateTime': 'datetime', 'date': 'date', 'time': 'time',
    'base64Binary': 'base64', 'hexBinary': 'hex',
    'anyType': 'any', 'anySimpleType': 'any',
    'duration': 'duration', 'guid': 'guid',
}

# Well-known namespace → preferred short prefix
_KNOWN_NS_PREFIXES: Dict[str, str] = {
    'http://schemas.datacontract.org': 'dc',
    'http://schemas.microsoft.com/2003/10/Serialization/': 'ser',
    'http://www.w3.org/2001/XMLSchema-instance': 'xsi',
    'http://www.w3.org/2001/XMLSchema': 'xs',
}


def _q(ns: str, tag: str) -> str:
    return f'{{{ns}}}{tag}'


def _local(tag: str) -> str:
    return tag.split('}')[-1] if '}' in tag else tag


def _hint(xs_local: str, optional: bool) -> str:
    base = _XS_HINTS.get(xs_local, xs_local)
    return f'[{base}{"?" if optional else ""}]'


# ---------------------------------------------------------------------------
# Internal parameter tree
# ---------------------------------------------------------------------------

@dataclass
class _Param:
    """Tree node for building a typed SOAP body element."""
    name: str
    ns: str = ''                 # element namespace; '' = inherit op namespace
    hint: Optional[str] = None  # leaf text like '[string]'; None = container node
    children: list = field(default_factory=list)  # list[_Param]


# ---------------------------------------------------------------------------
# Public data classes
# ---------------------------------------------------------------------------

@dataclass
class WsdlOperation:
    name: str
    soap_action: str
    endpoint_url: str
    soap_version: str       # '1.1' or '1.2'
    target_namespace: str
    body_template: str


@dataclass
class WsdlParseResult:
    service_name: str
    endpoint_url: str
    operations: List[WsdlOperation] = field(default_factory=list)
    error: Optional[str] = None


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def parse_wsdl(xml_content: str) -> WsdlParseResult:
    """Auto-detect WSDL 1.1 / 2.0 and return service info + operations."""
    try:
        root = ET.fromstring(xml_content)
    except ET.ParseError as e:
        return WsdlParseResult(service_name='', endpoint_url='', error=f'Invalid XML: {e}')

    local = _local(root.tag)
    if local == 'definitions':
        return _parse_wsdl11(root)
    elif local == 'description':
        return _parse_wsdl20(root)
    else:
        return WsdlParseResult(
            service_name='', endpoint_url='',
            error='Document is not a valid WSDL 1.1 (definitions) or WSDL 2.0 (description) file'
        )


def build_http_request(operation: WsdlOperation):
    """Convert a WsdlOperation into an HttpRequest ready to send."""
    from .models import HttpRequest

    headers: Dict[str, str] = {}
    if operation.soap_version == '1.2':
        ct = 'application/soap+xml; charset=utf-8'
        if operation.soap_action:
            ct += f'; action="{operation.soap_action}"'
        headers['Content-Type'] = ct
    else:
        headers['Content-Type'] = 'text/xml; charset=utf-8'
        if operation.soap_action:
            headers['SOAPAction'] = f'"{operation.soap_action}"'

    return HttpRequest(
        method='POST',
        url=operation.endpoint_url,
        headers=headers,
        body=operation.body_template,
        syntax='XML',
    )


# ---------------------------------------------------------------------------
# WSDL 1.1 parser
# ---------------------------------------------------------------------------

def _parse_wsdl11(root: ET.Element) -> WsdlParseResult:
    target_ns  = root.get('targetNamespace', '')
    service_name = root.get('name', '') or 'WSDL Service'

    # Service name + endpoint URL
    service_el = root.find(_q(_WSDL, 'service'))
    if service_el is None:
        service_el = root.find('service')
    if service_el is not None and service_el.get('name'):
        service_name = service_el.get('name')

    endpoint_url    = ''
    default_soap_ver = '1.1'
    if service_el is not None:
        for port in service_el:
            addr11 = port.find(_q(_SOAP11, 'address'))
            if addr11 is not None:
                endpoint_url    = addr11.get('location', '')
                default_soap_ver = '1.1'
                break
            addr12 = port.find(_q(_SOAP12, 'address'))
            if addr12 is not None:
                endpoint_url    = addr12.get('location', '')
                default_soap_ver = '1.2'
                break

    # Collect (soap_action, soap_version) per operation from bindings
    op_info: Dict[str, Tuple[str, str]] = {}
    for binding in root.iter():
        if _local(binding.tag) != 'binding':
            continue
        is11 = binding.find(_q(_SOAP11, 'binding')) is not None
        is12 = binding.find(_q(_SOAP12, 'binding')) is not None
        if not is11 and not is12:
            continue
        bv = '1.2' if is12 else '1.1'
        for op in binding:
            if _local(op.tag) != 'operation':
                continue
            op_name = _local(op.get('name', ''))
            if not op_name:
                continue
            soap_op = op.find(_q(_SOAP11, 'operation'))
            if soap_op is None:
                soap_op = op.find(_q(_SOAP12, 'operation'))
            action = soap_op.get('soapAction', '') if soap_op is not None else ''
            if op_name not in op_info:
                op_info[op_name] = (action, bv)

    # Fallback: portType when no SOAP binding found
    if not op_info:
        for pt in root.iter():
            if _local(pt.tag) != 'portType':
                continue
            for op in pt:
                if _local(op.tag) == 'operation':
                    op_name = op.get('name', '')
                    if op_name and op_name not in op_info:
                        op_info[op_name] = ('', default_soap_ver)

    if not op_info:
        return WsdlParseResult(
            service_name=service_name, endpoint_url=endpoint_url,
            error='No SOAP operations found in this WSDL document'
        )

    # Build schema maps: name → (element, namespace)
    elem_map, type_map = _build_schema_maps(root, _WSDL)

    operations = []
    for op_name, (action, ver) in op_info.items():
        params = _extract_params_wsdl11(root, op_name, elem_map, type_map)
        body   = _build_envelope(op_name, target_ns, ver, params)
        operations.append(WsdlOperation(
            name=op_name, soap_action=action, endpoint_url=endpoint_url,
            soap_version=ver, target_namespace=target_ns, body_template=body,
        ))

    return WsdlParseResult(
        service_name=service_name or 'WSDL Service',
        endpoint_url=endpoint_url,
        operations=operations,
    )


def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> list:
    """Return list[_Param] for the input of a WSDL 1.1 operation."""
    input_elem_name = _find_input_elem_wsdl11(root, op_name)

    # Naming-convention fallback
    if not input_elem_name:
        for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
            if candidate in elem_map:
                input_elem_name = candidate
                break

    if not input_elem_name or input_elem_name not in elem_map:
        return []

    elem, elem_ns = elem_map[input_elem_name]
    return _parse_element(elem, elem_ns, elem_map, type_map)


def _find_input_elem_wsdl11(root, op_name: str) -> Optional[str]:
    """Walk portType → input message → part/@element and return local element name."""
    for pt in root.iter():
        if _local(pt.tag) != 'portType':
            continue
        for op in pt:
            if _local(op.tag) != 'operation' or op.get('name') != op_name:
                continue
            inp = op.find(_q(_WSDL, 'input'))
            if inp is None:
                inp = op.find('input')
            if inp is None:
                return None
            msg_local = (inp.get('message') or '').split(':')[-1]
            for msg in root.iter():
                if _local(msg.tag) != 'message' or msg.get('name') != msg_local:
                    continue
                for part in msg:
                    if _local(part.tag) != 'part':
                        continue
                    elem_ref = part.get('element', '')
                    if elem_ref:
                        return elem_ref.split(':')[-1]
                return None
    return None


# ---------------------------------------------------------------------------
# WSDL 2.0 parser
# ---------------------------------------------------------------------------

def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
    target_ns    = root.get('targetNamespace', '')
    service_name = root.get('name', '') or 'WSDL Service'

    # Service element
    service_el = root.find(_q(_WSDL2, 'service'))
    if service_el is None:
        service_el = root.find('service')
    if service_el is not None and service_el.get('name'):
        service_name = service_el.get('name')

    # Endpoint address
    endpoint_url = ''
    if service_el is not None:
        for ep in service_el:
            if _local(ep.tag) == 'endpoint':
                addr = ep.get('address', '')
                if addr:
                    endpoint_url = addr
                    break

    # Determine which binding the service uses
    service_binding_local = None
    if service_el is not None:
        for ep in service_el:
            if _local(ep.tag) == 'endpoint':
                b_ref = ep.get('binding', '')
                service_binding_local = b_ref.split(':')[-1]
                break

    # Collect (soap_action) per operation from SOAP bindings
    binding_ops: Dict[str, Dict[str, str]] = {}
    for binding in root.iter():
        if _local(binding.tag) != 'binding':
            continue
        b_name = binding.get('name', '')
        b_type = binding.get('type', '')
        is_soap = (_WSDL2_SOAP in b_type or 'soap' in b_type.lower())
        if not is_soap:
            is_soap = any(
                _WSDL2_SOAP in (child.tag or '') or 'soap' in _local(child.tag).lower()
                for child in binding
            )
        if not is_soap:
            continue

        ops_actions: Dict[str, str] = {}
        for child in binding:
            if _local(child.tag) != 'operation':
                continue
            ref  = (child.get('ref') or '').split(':')[-1]
            action = (
                child.get(_q(_WSDL2_SOAP, 'action'))
                or child.get('action')
                or ''
            )
            if ref:
                ops_actions[ref] = action
        binding_ops[b_name] = ops_actions

    # Choose the right binding's operations
    op_info: Dict[str, str] = {}  # op_name → soap_action
    if service_binding_local and service_binding_local in binding_ops:
        op_info = binding_ops[service_binding_local]
    else:
        for ops in binding_ops.values():
            for op_name, action in ops.items():
                if op_name not in op_info:
                    op_info[op_name] = action

    # Fallback: collect from interface operations
    if not op_info:
        for iface in root.iter():
            if _local(iface.tag) != 'interface':
                continue
            for op in iface:
                if _local(op.tag) == 'operation':
                    op_name = op.get('name', '')
                    if op_name and op_name not in op_info:
                        op_info[op_name] = ''

    if not op_info:
        return WsdlParseResult(
            service_name=service_name, endpoint_url=endpoint_url,
            error='No SOAP operations found in WSDL 2.0 document'
        )

    elem_map, type_map = _build_schema_maps(root, _WSDL2)

    operations = []
    for op_name, action in op_info.items():
        params = _extract_params_wsdl20(root, op_name, elem_map, type_map)
        body   = _build_envelope(op_name, target_ns, '1.2', params)
        operations.append(WsdlOperation(
            name=op_name, soap_action=action, endpoint_url=endpoint_url,
            soap_version='1.2', target_namespace=target_ns, body_template=body,
        ))

    return WsdlParseResult(
        service_name=service_name or 'WSDL Service',
        endpoint_url=endpoint_url,
        operations=operations,
    )


def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
    """Return list[_Param] for the input of a WSDL 2.0 operation."""
    for iface in root.iter():
        if _local(iface.tag) != 'interface':
            continue
        for op in iface:
            if _local(op.tag) != 'operation' or op.get('name') != op_name:
                continue
            for child in op:
                if _local(child.tag) == 'input':
                    elem_ref = (child.get('element') or '').split(':')[-1]
                    if elem_ref in elem_map:
                        elem, elem_ns = elem_map[elem_ref]
                        return _parse_element(elem, elem_ns, elem_map, type_map)

    # Naming-convention fallback
    for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
        if candidate in elem_map:
            elem, elem_ns = elem_map[candidate]
            return _parse_element(elem, elem_ns, elem_map, type_map)

    return []


# ---------------------------------------------------------------------------
# XSD schema helpers
# ---------------------------------------------------------------------------

def _build_schema_maps(root: ET.Element, wsdl_ns: str) -> Tuple[Dict, Dict]:
    """Build {name: (element, ns)} and {name: (complexType, ns)} maps from <types>.

    Each map value is a (ET.Element, targetNamespace) tuple so callers can
    track which schema namespace every element / type belongs to.
    """
    elem_map: Dict[str, Tuple[ET.Element, str]] = {}
    type_map: Dict[str, Tuple[ET.Element, str]] = {}

    types_el = root.find(_q(wsdl_ns, 'types'))
    if types_el is None:
        types_el = root.find('types')
    if types_el is None:
        return elem_map, type_map

    for node in types_el.iter():
        if _local(node.tag) != 'schema':
            continue
        schema_ns = node.get('targetNamespace', '')
        for child in node:
            name = child.get('name', '')
            if not name:
                continue
            loc = _local(child.tag)
            if loc == 'element':
                elem_map[name] = (child, schema_ns)
            elif loc == 'complexType':
                type_map[name] = (child, schema_ns)

    return elem_map, type_map


def _parse_element(elem: ET.Element, elem_ns: str, elem_map: Dict, type_map: Dict,
                   depth: int = 0) -> list:
    """Extract list[_Param] children from an xs:element."""
    if depth > 4:
        return []

    # Inline complexType
    ct = elem.find(_q(_XS, 'complexType'))
    if ct is not None:
        return _parse_complex_type(ct, elem_ns, elem_map, type_map, depth)

    # Named type reference
    type_ref   = elem.get('type', '')
    type_local = type_ref.split(':')[-1] if type_ref else ''
    if type_local:
        if type_local in _XS_HINTS:
            return []   # simple scalar — not a parameter container
        entry = type_map.get(type_local)
        if entry is not None:
            ct, type_ns = entry
            return _parse_complex_type(ct, type_ns, elem_map, type_map, depth)

    return []


def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
                         depth: int = 0) -> list:
    """Extract list[_Param] from an xs:complexType."""
    params: list = []

    # xs:complexContent / xs:extension (inheritance)
    cc = ct.find(_q(_XS, 'complexContent'))
    if cc is not None:
        ext = cc.find(_q(_XS, 'extension'))
        if ext is not None:
            base_local = (ext.get('base') or '').split(':')[-1]
            entry = type_map.get(base_local)
            if entry is not None:
                base_ct, base_ns = entry
                params.extend(_parse_complex_type(base_ct, base_ns, elem_map, type_map, depth + 1))
            for tag in ('sequence', 'all', 'choice'):
                seq = ext.find(_q(_XS, tag))
                if seq is not None:
                    params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
                    break
        return params

    # Direct sequence / all / choice
    for tag in ('sequence', 'all', 'choice'):
        seq = ct.find(_q(_XS, tag))
        if seq is not None:
            params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
            break

    return params


def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
                    depth: int = 0) -> list:
    """Extract list[_Param] from xs:sequence / xs:all / xs:choice.

    Complex child elements are kept as container _Param nodes (preserving the
    wrapper element), rather than being flattened into the parent list.
    Child elements of a referenced type carry that type's namespace.
    """
    params: list = []
    choice_optional = _local(seq.tag) == 'choice'

    for child in seq:
        loc = _local(child.tag)

        if loc == 'element':
            name = child.get('name', '')
            if not name:
                ref_local = (child.get('ref') or '').split(':')[-1]
                if ref_local:
                    name = ref_local

            if not name:
                continue

            optional  = choice_optional or child.get('minOccurs', '1') == '0'
            type_ref   = child.get('type', '')
            type_local = type_ref.split(':')[-1] if type_ref else ''

            if type_local and type_local in _XS_HINTS:
                params.append(_Param(name=name, ns=ns, hint=_hint(type_local, optional)))
            else:
                inline_ct = child.find(_q(_XS, 'complexType'))
                if inline_ct is not None and depth < 3:
                    sub = _parse_complex_type(inline_ct, ns, elem_map, type_map, depth + 1)
                    if sub:
                        params.append(_Param(name=name, ns=ns, children=sub))
                    else:
                        params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
                elif type_local and type_local in type_map and depth < 3:
                    child_ct, child_ns = type_map[type_local]
                    sub = _parse_complex_type(child_ct, child_ns, elem_map, type_map, depth + 1)
                    if sub:
                        # Keep wrapper element; children carry child_ns namespace
                        params.append(_Param(name=name, ns=ns, children=sub))
                    else:
                        params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
                else:
                    params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))

        elif loc in ('sequence', 'all', 'choice') and depth < 4:
            params.extend(_parse_sequence(child, ns, elem_map, type_map, depth + 1))

    return params


# ---------------------------------------------------------------------------
# SOAP envelope builder
# ---------------------------------------------------------------------------

def _assign_ns_prefix(ns: str, ns_to_pfx: Dict[str, str], used_pfx: set) -> str:
    """Return an existing or newly-assigned XML prefix for *ns*."""
    if ns in ns_to_pfx:
        return ns_to_pfx[ns]

    # Check well-known namespaces first (prefix-match)
    candidate = ''
    for known_ns, known_pfx in _KNOWN_NS_PREFIXES.items():
        if ns.startswith(known_ns):
            candidate = known_pfx
            break

    if not candidate:
        # Derive a short name from the last meaningful URL path segment
        last = ns.rstrip('/').rsplit('/', 1)[-1]
        base = ''.join(c for c in last.lower() if c.isalpha())[:4]
        _generic = {'org', 'com', 'net', 'gov', 'www', 'http', 'wsdl', 'soap', ''}
        if base in _generic:
            parts = ns.rstrip('/').split('/')
            for part in reversed(parts):
                seg = ''.join(c for c in part.lower() if c.isalpha())[:4]
                if seg and seg not in _generic:
                    base = seg
                    break
        candidate = base or 'ns'

    # Ensure uniqueness
    orig, i = candidate, 1
    while candidate in used_pfx:
        candidate = f'{orig}{i}'
        i += 1

    ns_to_pfx[ns] = candidate
    used_pfx.add(candidate)
    return candidate


def _build_envelope(op_name: str, target_ns: str, soap_version: str,
                    params=None) -> str:
    env_ns = _ENV12 if soap_version == '1.2' else _ENV11

    if params:
        # --- collect all unique namespaces in tree order ---
        ns_order: List[str] = []
        ns_seen: set = set()

        def _collect_ns(ps):
            for p in ps:
                if p.ns and p.ns not in ns_seen:
                    ns_order.append(p.ns)
                    ns_seen.add(p.ns)
                _collect_ns(p.children)

        if target_ns and target_ns not in ns_seen:
            ns_order.append(target_ns)
            ns_seen.add(target_ns)
        _collect_ns(params)

        # --- assign prefixes ---
        ns_to_pfx: Dict[str, str] = {}
        used_pfx: set = set()

        # Target namespace always gets 'tns' (consistent with the no-params branch)
        if target_ns:
            ns_to_pfx[target_ns] = 'tns'
            used_pfx.add('tns')

        for ns in ns_order:
            if ns not in ns_to_pfx:
                _assign_ns_prefix(ns, ns_to_pfx, used_pfx)

        # --- namespace declarations on the operation element ---
        ns_decls = ' '.join(
            f'xmlns:{ns_to_pfx[ns]}="{ns}"'
            for ns in ns_order
            if ns in ns_to_pfx
        )

        # --- recursive XML renderer ---
        def _render(ps, indent: str) -> List[str]:
            lines: List[str] = []
            for p in ps:
                pfx = ns_to_pfx.get(p.ns or target_ns, '')
                tag = f'{pfx}:{p.name}' if pfx else p.name
                if p.hint is not None:
                    lines.append(f'{indent}<{tag}>{p.hint}</{tag}>')
                elif p.children:
                    lines.append(f'{indent}<{tag}>')
                    lines.extend(_render(p.children, indent + '  '))
                    lines.append(f'{indent}</{tag}>')
                else:
                    lines.append(f'{indent}<{tag}/>')
            return lines

        op_pfx = ns_to_pfx.get(target_ns, '')
        op_tag = f'{op_pfx}:{op_name}' if op_pfx else op_name
        op_open = f'<{op_tag} {ns_decls}>' if ns_decls else f'<{op_tag}>'

        body_lines = [f'    {op_open}']
        body_lines.extend(_render(params, '      '))
        body_lines.append(f'    </{op_tag}>')
        body = '\n'.join(body_lines)

        return (
            f'<?xml version="1.0" encoding="utf-8"?>\n'
            f'<soap:Envelope xmlns:soap="{env_ns}">\n'
            f'  <soap:Header/>\n'
            f'  <soap:Body>\n'
            f'{body}\n'
            f'  </soap:Body>\n'
            f'</soap:Envelope>'
        )
    else:
        ns_decl = f'\n               xmlns:tns="{target_ns}"' if target_ns else ''
        op_el   = f'tns:{op_name}' if target_ns else op_name
        return (
            f'<?xml version="1.0" encoding="utf-8"?>\n'
            f'<soap:Envelope xmlns:soap="{env_ns}"{ns_decl}>\n'
            f'  <soap:Header/>\n'
            f'  <soap:Body>\n'
            f'    <{op_el}>\n'
            f'      <!-- Add parameters here -->\n'
            f'    </{op_el}>\n'
            f'  </soap:Body>\n'
            f'</soap:Envelope>'
        )
