"""Minimal VIM SOAP SDK for pyvmomi / govmomi style clients."""

from __future__ import annotations

import re
from xml.sax.saxutils import escape

from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse

from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.security.session import (
    SESSION_HEADER,
    create_session,
    delete_session,
    ensure_default_credentials,
    lookup_session,
    verify_password,
)

router = APIRouter(tags=["vSphere SOAP"])

NS_SOAP = "http://schemas.xmlsoap.org/soap/envelope/"
# Keep ≤3 dotted components — hashicorp/vsphere parses AboutInfo.version strictly.
VIM_VERSION = "8.0.2"


class SoapFaultError(Exception):
    def __init__(self, fault_code: str, message: str) -> None:
        self.fault_code = fault_code
        self.message = message
        super().__init__(message)


@router.get("/sdk")
@router.get("/sdk/")
async def sdk_get() -> PlainTextResponse:
    return PlainTextResponse(
        "VMware VIM SDK simulator — POST SOAP bodies to /sdk",
        media_type="text/plain",
    )


@router.get("/sdk/about.do")
@router.get("/about.do")
async def sdk_about() -> Response:
    html = (
        "<!DOCTYPE html><html><head><title>VMware vCenter</title></head>"
        f"<body><h1>VMware vCenter Server</h1><p>Version {VIM_VERSION} (API Simulator)</p>"
        "<p>SOAP endpoint: <code>/sdk</code> · "
        '<a href="/sdk/vimService.wsdl">WSDL</a></p></body></html>'
    )
    return Response(content=html, media_type="text/html")


@router.get("/sdk/vimService.wsdl")
@router.get("/sdk/vim.wsdl")
async def sdk_wsdl() -> Response:
    ops = [
        "RetrieveServiceContent",
        "Login",
        "Logout",
        "RetrieveProperties",
        "RetrievePropertiesEx",
        "ContinueRetrievePropertiesEx",
        "CreateFilter",
        "WaitForUpdatesEx",
        "CreateContainerView",
        "DestroyPropertyFilter",
        "FindByInventoryPath",
        "FindByUuid",
        "FindByDnsName",
        "FindByIp",
        "FindChild",
        "CreateVM_Task",
        "CreateChildVM_Task",
        "CreateFolder",
        "PowerOnVM_Task",
        "PowerOffVM_Task",
        "CloneVM_Task",
        "CreateSnapshot_Task",
        "Rename_Task",
        "ReconfigVM_Task",
        "RelocateVM_Task",
        "Destroy_Task",
        "CustomizeVM_Task",
        "CancelTask",
        "CurrentTime",
        "InitiateFileTransferToGuest",
        "InitiateFileTransferFromGuest",
        "ListFilesInGuest",
        "DeleteFileInGuest",
        "MakeDirectoryInGuest",
        "ImportVApp_Task",
        "CreateImportSpec",
        "HttpNfcLeaseComplete",
        "HttpNfcLeaseProgress",
        "HttpNfcLeaseAbort",
        "HttpNfcLeaseGetManifest",
        "QueryConfigOption",
        "QueryConfigOptionEx",
        "QueryConfigOptionDescriptor",
        "QueryConfigTarget",
    ]
    elements = "".join(f'<element name="{op}"><complexType/></element>' for op in ops)
    messages = "".join(
        f'<message name="{op}Request"><part name="parameters" element="vim25:{op}"/></message>'
        for op in ops
    )
    operations = "".join(
        f'<operation name="{op}"><input message="vim25:{op}Request"/></operation>' for op in ops
    )
    wsdl = f"""<?xml version="1.0" encoding="UTF-8"?>
<definitions name="VimService" targetNamespace="urn:vim25"
  xmlns="http://schemas.xmlsoap.org/wsdl/"
  xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
  xmlns:vim25="urn:vim25">
  <types><schema targetNamespace="urn:vim25" xmlns="http://www.w3.org/2001/XMLSchema">{elements}</schema></types>
  {messages}
  <portType name="VimPortType">{operations}</portType>
  <binding name="VimBinding" type="vim25:VimPortType">
    <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
  </binding>
  <service name="VimService">
    <port name="VimPort" binding="vim25:VimBinding">
      <soap:address location="/sdk"/>
    </port>
  </service>
</definitions>
"""
    return Response(content=wsdl, media_type="text/xml")


@router.post("/sdk")
@router.post("/sdk/")
async def sdk_post(
    request: Request,
    database: Database = Depends(get_database),
) -> Response:
    body = (await request.body()).decode("utf-8", errors="replace")
    action = _soap_action(request, body)
    try:
        xml, session_id = await _dispatch(action, body, request, database)
    except SoapFaultError as fault:
        return Response(
            content=_fault_envelope(fault),
            media_type='text/xml; charset="utf-8"',
            status_code=500,
        )
    headers: dict[str, str] = {}
    if session_id:
        headers["Set-Cookie"] = f'vmware_soap_session="{session_id}"; Path=/; Secure; HttpOnly'
        headers[SESSION_HEADER] = session_id
    return Response(content=xml, media_type='text/xml; charset="utf-8"', headers=headers)


@router.post("/sdk/vim25/{version}/SessionManager/SessionManager/Login")
async def vim25_json_login(
    version: str,
    request: Request,
    database: Database = Depends(get_database),
) -> Response:
    del version
    await ensure_default_credentials(database)
    payload = await request.json()
    username = str(payload.get("userName") or payload.get("username") or "")
    password = str(payload.get("password") or "")
    if not await verify_password(database, username, password):
        return JSONResponse({"error": "InvalidLogin"}, status_code=401)
    session_id = await create_session(database, username)
    return Response(
        content="null",
        media_type="application/json",
        headers={SESSION_HEADER: session_id},
        status_code=200,
    )


async def _dispatch(
    action: str,
    body: str,
    request: Request,
    database: Database,
) -> tuple[str, str | None]:
    if "RetrieveServiceContent" in action or "RetrieveServiceContent" in body:
        return _service_content_response(), None
    if _has_op(action, body, "Login"):
        xml, session_id = await _login_response(body, database)
        return xml, session_id
    if _has_op(action, body, "Logout"):
        session_id = request.headers.get(SESSION_HEADER) or _cookie_session(request)
        if session_id:
            await delete_session(database, session_id)
        return _empty_response("LogoutResponse"), None
    if _has_op(action, body, "CreateContainerView"):
        return await _create_container_view(body, database), None
    if _has_op(action, body, "ContinueRetrievePropertiesEx"):
        return await _continue_retrieve_properties(body, request, database), None
    if (
        "RetrieveProperties" in action
        or "RetrievePropertiesEx" in body
        or _has_op(action, body, "RetrieveProperties")
        or _has_op(action, body, "RetrievePropertiesEx")
    ):
        return await _retrieve_properties(body, request, database), None
    if "DestroyPropertyFilter" in body or "DestroyContainerView" in body or "DestroyView" in body:
        return _empty_response("DestroyPropertyFilterResponse"), None
    if "CurrentTime" in action or _has_op(action, body, "CurrentTime"):
        return _wrap("CurrentTimeResponse", "<returnval>2026-01-01T00:00:00.000Z</returnval>"), None
    if _has_op(action, body, "FindByInventoryPath"):
        return await _find_by_path(body, database), None
    if _has_op(action, body, "FindChild"):
        return await _find_child(body, database), None
    if (
        _has_op(action, body, "FindByUuid")
        or _has_op(action, body, "FindByDnsName")
        or _has_op(action, body, "FindByIp")
    ):
        return await _find_by_attr(body, database), None
    if _has_op(action, body, "CreateFilter"):
        return _wrap(
            "CreateFilterResponse",
            '<returnval type="PropertyFilter">filter-1</returnval>',
        ), None
    if (
        _has_op(action, body, "WaitForUpdatesEx")
        or _has_op(action, body, "WaitForUpdates")
        or _has_op(action, body, "CheckForUpdates")
    ):
        return await _wait_for_updates(body, request, database), None
    if _has_op(action, body, "CancelTask"):
        return await _cancel_task(body, database), None
    if _has_op(action, body, "CreateVM_Task") or _has_op(action, body, "CreateChildVM_Task"):
        return await _create_vm_task(body, request, database), None
    if _has_op(action, body, "CreateFolder"):
        return await _create_folder(body, request, database), None
    if _has_op(action, body, "CloneVM_Task"):
        return await _clone_vm_task(body, request, database), None
    if _has_op(action, body, "CreateSnapshot_Task"):
        return await _snapshot_task(body, request, database), None
    if _has_op(action, body, "CustomizeVM_Task"):
        return await _customize_vm_task(body, request, database), None
    if _has_op(action, body, "QueryTask") or ("TaskManager" in body and "info" in body):
        return await _query_task(body, database), None
    if (
        _has_op(action, body, "PowerOnVM_Task")
        or _has_op(action, body, "PowerOffVM_Task")
        or _has_op(action, body, "ResetVM_Task")
        or _has_op(action, body, "SuspendVM_Task")
    ):
        return await _power_task(body, request, database), None
    if _has_op(action, body, "Rename_Task"):
        return await _rename_task(body, request, database), None
    if _has_op(action, body, "MarkAsTemplate") or _has_op(action, body, "MarkAsVirtualMachine"):
        return await _template_task(body, request, database), None
    if _has_op(action, body, "UnregisterVM") or _has_op(action, body, "Destroy_Task"):
        return await _destroy_or_unregister(body, request, database), None
    if _has_op(action, body, "ReconfigVM_Task"):
        return await _reconfig_vm_task(body, request, database), None
    if _has_op(action, body, "MigrateVM_Task") or _has_op(action, body, "RelocateVM_Task"):
        return await _relocate_task(body, request, database), None
    if (
        _has_op(action, body, "ListFilesInGuest")
        or _has_op(action, body, "InitiateFileTransferToGuest")
        or _has_op(action, body, "InitiateFileTransferFromGuest")
        or _has_op(action, body, "DeleteFileInGuest")
        or _has_op(action, body, "MakeDirectoryInGuest")
    ):
        return await _guest_file_ops(body, request, database), None
    if (
        _has_op(action, body, "ImportVApp_Task")
        or _has_op(action, body, "CreateImportSpec")
        or _has_op(action, body, "HttpNfcLeaseComplete")
        or _has_op(action, body, "HttpNfcLeaseProgress")
        or _has_op(action, body, "HttpNfcLeaseAbort")
        or _has_op(action, body, "HttpNfcLeaseGetManifest")
        or _has_op(action, body, "HttpNfcLease")
    ):
        return await _nfc_lease_ops(body, request, database), None
    if (
        _has_op(action, body, "QueryConfigOptionEx")
        or _has_op(action, body, "QueryConfigOption")
        or _has_op(action, body, "QueryConfigOptionDescriptor")
        or _has_op(action, body, "QueryConfigTarget")
    ):
        return _environment_browser_ops(action, body), None
    if _has_op(action, body, "QueryEvents") or _has_op(action, body, "RetrieveArgumentDescription"):
        return _wrap("QueryEventsResponse", "<returnval></returnval>"), None
    if (
        _has_op(action, body, "QueryAlarmState")
        or _has_op(action, body, "GetAlarm")
        or _has_op(action, body, "AreAlarmActionsEnabled")
    ):
        return _wrap("QueryAlarmStateResponse", "<returnval></returnval>"), None
    if _has_op(action, body, "QueryPerf") or _has_op(action, body, "QueryPerfProviderSummary"):
        return _wrap("QueryPerfResponse", "<returnval></returnval>"), None
    return _empty_response("MethodFaultResponse"), None


def _has_op(action: str, body: str, name: str) -> bool:
    """Match SOAP op in SOAPAction or namespaced element tags (e.g. ``<urn:Login>``)."""

    if name in action:
        return True
    return re.search(rf"<(?:\w+:)?{re.escape(name)}(?:\s|>|/)", body) is not None


def _soap_action(request: Request, body: str) -> str:
    header = request.headers.get("SOAPAction") or request.headers.get("soapaction") or ""
    if header:
        return header.strip('"')
    match = re.search(r"<(\w+)", body)
    return match.group(1) if match else ""


def _cookie_session(request: Request) -> str | None:
    cookie = request.headers.get("cookie") or ""
    for part in cookie.split(";"):
        part = part.strip()
        if part.startswith("vmware_soap_session="):
            return part.split("=", 1)[1].strip('"')
    return None


async def _require_soap_session(request: Request, database: Database) -> str:
    session_id = request.headers.get(SESSION_HEADER) or _cookie_session(request)
    if not session_id:
        raise SoapFaultError("NotAuthenticated", "Session required")
    info = await lookup_session(database, session_id)
    if info is None:
        raise SoapFaultError("NotAuthenticated", "Invalid session")
    return session_id


async def _login_response(body: str, database: Database) -> tuple[str, str]:
    await ensure_default_credentials(database)
    username = _xml_text(body, "userName") or _xml_text(body, "username") or ""
    password = _xml_text(body, "password") or ""
    if not await verify_password(database, username, password):
        raise SoapFaultError(
            "InvalidLogin",
            "Cannot complete login due to an incorrect user name or password",
        )
    session_id = await create_session(database, username)
    xml = _wrap(
        "LoginResponse",
        f"""<returnval type="UserSession">
      <key>{escape(session_id)}</key>
      <userName>{escape(username)}</userName>
      <fullName>{escape(username)}</fullName>
      <loginTime>2026-01-01T00:00:00.000Z</loginTime>
      <lastActiveTime>2026-01-01T00:00:00.000Z</lastActiveTime>
      <locale>en</locale>
      <messageLocale>en</messageLocale>
    </returnval>""",
    )
    return xml, session_id


async def _create_container_view(body: str, database: Database) -> str:
    from app.vsphere.soap import property_collector as pc

    types = pc.parse_view_types(body) or ["VirtualMachine"]
    objects = await inventory.list_objects(database)
    moids = [o.moid for o in objects if o.type in types]
    view_id = await pc.next_view_id(database)
    await pc.register_container_view(database, view_id, moids)
    return _wrap(
        "CreateContainerViewResponse",
        f'<returnval type="ContainerView">{escape(view_id)}</returnval>',
    )


async def _retrieve_properties(body: str, request: Request, database: Database) -> str:
    from app.vsphere.domain import tasks as task_store
    from app.vsphere.soap import property_collector as pc

    await _require_soap_session(request, database)
    selected, path_sets = await pc.select_objects_for_retrieve(database, body)
    all_objects = await inventory.list_objects(database)
    by_moid = {o.moid: o for o in all_objects}
    enriched = []
    for obj in selected:
        if obj.type == "Task":
            task = await task_store.get_task(database, obj.moid)
            result = (task or {}).get("result") or {}
            props = {
                "state": {
                    "SUCCEEDED": "success",
                    "FAILED": "error",
                    "RUNNING": "running",
                    "PENDING": "queued",
                }.get((task or {}).get("status", ""), "error"),
                "operation": (task or {}).get("operation") or "task",
                "entity": result.get("vm"),
                "result_moid": result.get("vm"),
                "result_type": "VirtualMachine" if result.get("vm") else None,
            }
            from app.vsphere.inventory import ManagedObject

            enriched.append(
                ManagedObject(
                    moid=obj.moid, type="Task", name=obj.moid, parent_moid=None, props=props
                )
            )
        else:
            enriched.append(obj)
    selected = enriched
    # Match SOAP op name carefully — ContinueRetrievePropertiesEx also contains this substring.
    is_ex = "RetrievePropertiesEx" in body and "ContinueRetrievePropertiesEx" not in body
    max_objects = 100 if is_ex else None
    token = None
    if max_objects is not None and len(selected) > max_objects:
        page, rest = selected[:max_objects], selected[max_objects:]
        selected = page
        token = await pc.store_page_token(
            database, [o.moid for o in rest], path_sets=path_sets or None
        )
    parts = [
        pc.object_content_xml(
            obj,
            children=pc.children_of(obj.moid, all_objects),
            all_by_moid=by_moid,
            path_sets=path_sets or None,
        )
        for obj in selected
    ]
    tag = (
        "RetrievePropertiesExResponse"
        if "RetrievePropertiesEx" in body
        else "RetrievePropertiesResponse"
    )
    # RetrieveResult.objects (plural) — govmomi/Terraform panic on <object>.
    if token:
        objects_xml = "".join(
            p.replace("<returnval>", "<objects>").replace("</returnval>", "</objects>")
            for p in parts
        )
        return _wrap(tag, f"<returnval>{objects_xml}<token>{escape(token)}</token></returnval>")
    if "RetrievePropertiesEx" in body:
        objects_xml = "".join(
            p.replace("<returnval>", "<objects>").replace("</returnval>", "</objects>")
            for p in parts
        )
        return _wrap(tag, f"<returnval>{objects_xml}</returnval>")
    return _wrap(tag, "".join(parts))


async def _continue_retrieve_properties(body: str, request: Request, database: Database) -> str:
    """ContinueRetrievePropertiesEx — required when RetrievePropertiesEx paginates."""

    from app.vsphere.soap import property_collector as pc

    await _require_soap_session(request, database)
    token = pc.parse_continue_token(body)
    if not token:
        return _wrap("ContinueRetrievePropertiesExResponse", "<returnval></returnval>")
    page_full = await pc.take_page_token_full(database, token)
    if page_full is None:
        return _wrap("ContinueRetrievePropertiesExResponse", "<returnval></returnval>")
    remaining, path_sets = page_full
    all_objects = await inventory.list_objects(database)
    by_moid = {o.moid: o for o in all_objects}
    selected = [by_moid[m] for m in remaining if m in by_moid]
    max_objects = 100
    next_token = None
    if len(selected) > max_objects:
        page, rest = selected[:max_objects], selected[max_objects:]
        selected = page
        next_token = await pc.store_page_token(
            database, [o.moid for o in rest], path_sets=path_sets or None
        )
    parts = [
        pc.object_content_xml(
            obj,
            children=pc.children_of(obj.moid, all_objects),
            all_by_moid=by_moid,
            path_sets=path_sets or None,
        )
        for obj in selected
    ]
    objects_xml = "".join(
        p.replace("<returnval>", "<objects>").replace("</returnval>", "</objects>") for p in parts
    )
    if next_token:
        return _wrap(
            "ContinueRetrievePropertiesExResponse",
            f"<returnval>{objects_xml}<token>{escape(next_token)}</token></returnval>",
        )
    return _wrap("ContinueRetrievePropertiesExResponse", f"<returnval>{objects_xml}</returnval>")


async def _power_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import tasks as task_store

    moid = _xml_text(body, "_this") or ""
    obj = await inventory.get_object(database, moid)
    if obj is None or obj.type != "VirtualMachine":
        raise SoapFaultError("ManagedObjectNotFound", f"VM {moid} not found")
    props = dict(obj.props)
    if "PowerOnVM_Task" in body or "ResetVM_Task" in body:
        props["power_state"] = "POWERED_ON"
        op = "PowerOn"
    elif "PowerOffVM_Task" in body:
        props["power_state"] = "POWERED_OFF"
        op = "PowerOff"
    else:
        props["power_state"] = "SUSPENDED"
        op = "Suspend"
    await inventory.update_props(database, moid, props)
    task_id = await task_store.create_task(
        database,
        description=f"{op} {moid}",
        service="vim.VirtualMachine",
        operation=op.lower(),
        result={"vm": moid, "power_state": props["power_state"]},
    )
    tag = f"{op}VM_TaskResponse" if op != "Suspend" else "SuspendVM_TaskResponse"
    if op == "PowerOn" and "ResetVM_Task" in body:
        tag = "ResetVM_TaskResponse"
    return _wrap(tag, f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _wait_for_updates(body: str, request: Request, database: Database) -> str:
    from app.vsphere.soap import property_collector as pc

    await _require_soap_session(request, database)
    session_key = request.headers.get(SESSION_HEADER) or _cookie_session(request) or "anon"
    objects = await inventory.list_objects(database)
    inner = await pc.wait_updates_xml(database, session_key=session_key, body=body, objects=objects)
    if "CheckForUpdates" in body:
        return _wrap("CheckForUpdatesResponse", inner)
    if "WaitForUpdatesEx" in body:
        return _wrap("WaitForUpdatesExResponse", inner)
    return _wrap("WaitForUpdatesResponse", inner)


async def _cancel_task(body: str, database: Database) -> str:
    from app.vsphere.domain import tasks as task_store

    task_id = _xml_text(body, "_this") or ""
    task = await task_store.get_task(database, task_id)
    if task is None:
        raise SoapFaultError("ManagedObjectNotFound", f"Task {task_id} not found")
    return _empty_response("CancelTaskResponse")


async def _find_by_attr(body: str, database: Database) -> str:
    uuid = _xml_text(body, "uuid")
    dns = _xml_text(body, "dnsName")
    ip = _xml_text(body, "ip")
    objects = await inventory.list_objects(database)
    for obj in objects:
        identity = obj.props.get("identity") or {}
        if uuid and uuid in {
            str(identity.get("instance_uuid") or ""),
            str(identity.get("bios_uuid") or ""),
        }:
            return _wrap(
                "FindByUuidResponse",
                f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
            )
        if dns and (obj.name == dns or obj.props.get("ip_address") == dns):
            return _wrap(
                "FindByDnsNameResponse",
                f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
            )
        if ip and str(obj.props.get("ip_address") or "") == ip:
            return _wrap(
                "FindByIpResponse",
                f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
            )
    tag = "FindByUuidResponse" if uuid else "FindByDnsNameResponse" if dns else "FindByIpResponse"
    return _wrap(tag, "")


def _config_option_xml() -> str:
    """Minimal VirtualMachineConfigOption for Terraform DefaultDevices / OSFamily."""

    xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
    guests = [
        ("otherGuest64", "otherGuestFamily", "Other (64-bit)"),
        ("otherGuest", "otherGuestFamily", "Other (32-bit)"),
        ("ubuntu64Guest", "linuxGuest", "Ubuntu Linux (64-bit)"),
        ("rhel8_64Guest", "linuxGuest", "Red Hat Enterprise Linux 8 (64-bit)"),
        ("windows9_64Guest", "windowsGuest", "Microsoft Windows 10 (64-bit)"),
        ("windows2019srv_64Guest", "windowsGuest", "Microsoft Windows Server 2019 (64-bit)"),
    ]
    guest_xml = "".join(
        "<guestOSDescriptor>"
        f"<id>{escape(gid)}</id><family>{escape(family)}</family>"
        f"<fullName>{escape(full)}</fullName>"
        "<supportedMaxCPUs>128</supportedMaxCPUs>"
        "<supportedMaxMemMB>16777216</supportedMaxMemMB>"
        "<supportedMinMemMB>4</supportedMinMemMB>"
        "<supportedMaxDisks>60</supportedMaxDisks>"
        "<supportedMinDisks>0</supportedMinDisks>"
        "<supportedMaxDiskSizeMB>67108864</supportedMaxDiskSizeMB>"
        "<numSupportedPhysicalDisks>60</numSupportedPhysicalDisks>"
        "<numSupportedVirtualDisks>60</numSupportedVirtualDisks>"
        "<recommendedMemMB>1024</recommendedMemMB>"
        "<recommendedDiskGB>16</recommendedDiskGB>"
        "<recommendedColorDepth>8</recommendedColorDepth>"
        "<supportedDiskControllerList>VirtualLsiLogicController</supportedDiskControllerList>"
        "<supportedDiskControllerList>VirtualLsiLogicSASController</supportedDiskControllerList>"
        "<supportedDiskControllerList>ParaVirtualSCSIController</supportedDiskControllerList>"
        "<supportedEthernetCard>VirtualVmxnet3</supportedEthernetCard>"
        "<supportedEthernetCard>VirtualE1000e</supportedEthernetCard>"
        "<supportsWakeOnLan>true</supportsWakeOnLan>"
        "<supportsVMI>false</supportsVMI>"
        "<supportsMemoryHotAdd>true</supportsMemoryHotAdd>"
        "<supportsCpuHotAdd>true</supportsCpuHotAdd>"
        "<supportsCpuHotRemove>true</supportsCpuHotRemove>"
        f"</guestOSDescriptor>"
        for gid, family, full in guests
    )
    defaults = (
        f'<defaultDevice {xsi} xsi:type="VirtualIDEController">'
        "<key>200</key><busNumber>0</busNumber><deviceInfo>"
        "<label>IDE 0</label><summary>IDE 0</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualIDEController">'
        "<key>201</key><busNumber>1</busNumber><deviceInfo>"
        "<label>IDE 1</label><summary>IDE 1</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualPS2Controller">'
        "<key>300</key><deviceInfo><label>PS2 controller 0</label>"
        "<summary>PS2 controller 0</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualPCIController">'
        "<key>100</key><deviceInfo><label>PCI controller 0</label>"
        "<summary>PCI controller 0</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualSIOController">'
        "<key>400</key><deviceInfo><label>SIO Controller 0</label>"
        "<summary>SIO Controller 0</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualKeyboard">'
        "<key>600</key><deviceInfo><label>Keyboard</label>"
        "<summary>Keyboard</summary></deviceInfo></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualPointingDevice">'
        "<key>700</key><deviceInfo><label>Pointing device</label>"
        "<summary>Pointing device</summary></deviceInfo>"
        f'<backing {xsi} xsi:type="VirtualPointingDeviceDeviceBackingInfo">'
        "<hostPointingDevice>autodetect</hostPointingDevice></backing></defaultDevice>"
        f'<defaultDevice {xsi} xsi:type="VirtualMachineVideoCard">'
        "<key>500</key><deviceInfo><label>Video card</label>"
        "<summary>Video card</summary></deviceInfo>"
        "<videoRamSizeInKB>4096</videoRamSizeInKB></defaultDevice>"
    )
    return (
        "<version>vmx-19</version>"
        "<description>Default hardware for lab simulator</description>"
        "<guestOSDefaultIndex>0</guestOSDefaultIndex>"
        f"{guest_xml}{defaults}"
        "<hardwareOptions>"
        "<hwVersion>19</hwVersion>"
        "<virtualCPUCostUnit>mhz</virtualCPUCostUnit>"
        "</hardwareOptions>"
    )


def _environment_browser_ops(action: str, body: str) -> str:
    if _has_op(action, body, "QueryConfigOptionDescriptor"):
        return _wrap(
            "QueryConfigOptionDescriptorResponse",
            "<returnval>"
            "<key>vmx-19</key><description>ESXi 8.0 and later</description>"
            '<host type="HostSystem">host-11</host>'
            "<createSupported>true</createSupported>"
            "<defaultConfigOption>true</defaultConfigOption>"
            "<runSupported>true</runSupported>"
            "<upgradeSupported>true</upgradeSupported>"
            "</returnval>",
        )
    if _has_op(action, body, "QueryConfigTarget"):
        return _wrap(
            "QueryConfigTargetResponse",
            "<returnval>"
            "<numCpus>64</numCpus><numCpuCores>64</numCpuCores>"
            "<numNumaNodes>1</numNumaNodes>"
            "<smcPresent>false</smcPresent>"
            "</returnval>",
        )
    # QueryConfigOption / QueryConfigOptionEx
    tag = (
        "QueryConfigOptionExResponse"
        if _has_op(action, body, "QueryConfigOptionEx")
        else "QueryConfigOptionResponse"
    )
    return _wrap(tag, f"<returnval>{_config_option_xml()}</returnval>")


async def _create_vm_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    # CreateChildVM_Task is invoked on a ResourcePool/VirtualApp; place the VM in the
    # datacenter VM folder. CreateVM_Task is invoked on a Folder.
    if "CreateChildVM_Task" in body:
        folder = "group-v23"
    else:
        folder = _xml_text(body, "_this") or "group-v23"
    name = _xml_text(body, "name") or "unnamed-vm"
    guest = _xml_text(body, "guestId") or "otherGuest64"
    cpu = int(_xml_text(body, "numCPUs") or _xml_text(body, "numCpu") or "1")
    memory = int(_xml_text(body, "memoryMB") or "1024")
    pool = _xml_attr_or_text(body, "pool") or "resgroup-22"
    host = _xml_attr_or_text(body, "host") or "host-11"
    # Parse datastore from [datastore1] path or datastore MOR.
    datastore = _xml_attr_or_text(body, "datastore") or "datastore-31"
    path_name = _xml_text(body, "vmPathName") or ""
    if path_name.startswith("[") and "]" in path_name:
        ds_name = path_name[1 : path_name.index("]")]
        objects = await inventory.list_objects(database, type_name="Datastore")
        match = next((o for o in objects if o.name == ds_name or o.moid == ds_name), None)
        if match:
            datastore = match.moid
    # Optional network from VirtualEthernetCard backing.
    network = _xml_attr_or_text(body, "network") or "network-41"
    moid, task_id = await vm_ops.create_vm(
        database,
        name=name,
        folder=folder,
        host=host,
        datastore=datastore,
        resource_pool=pool,
        guest_os=_rest_guest_from_vim(guest),
        cpu_count=cpu,
        memory_size_mib=memory,
        networks=[network],
    )
    del moid
    tag = "CreateChildVM_TaskResponse" if "CreateChildVM_Task" in body else "CreateVM_TaskResponse"
    return _wrap(tag, f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _create_folder(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import inventory_ops

    parent = _xml_text(body, "_this") or "group-v23"
    name = _xml_text(body, "name") or "New Folder"
    moid = await inventory_ops.create_folder(database, parent=parent, name=name)
    return _wrap("CreateFolderResponse", f'<returnval type="Folder">{escape(moid)}</returnval>')


async def _clone_vm_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    source = _xml_text(body, "_this") or ""
    name = _xml_text(body, "name") or f"{source}-clone"
    folder = _xml_attr_or_text(body, "folder")
    host = _xml_attr_or_text(body, "host")
    datastore = _xml_attr_or_text(body, "datastore")
    pool = _xml_attr_or_text(body, "pool")
    power_on = (_xml_text(body, "powerOn") or "false").lower() == "true"
    moid, task_id = await vm_ops.clone_vm(
        database,
        source_vm=source,
        name=name,
        folder=folder,
        host=host,
        datastore=datastore,
        resource_pool=pool,
        power_on=power_on,
    )
    del moid
    return _wrap("CloneVM_TaskResponse", f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _customize_vm_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    hostname = _xml_text(body, "hostName") or _xml_text(body, "name")
    ip = _xml_text(body, "ipAddress") or _xml_text(body, "ip")
    task_id = await vm_ops.customize_vm(
        database,
        vm,
        {"hostname": hostname, "ip": ip, "raw": True},
    )
    return _wrap(
        "CustomizeVM_TaskResponse",
        f'<returnval type="Task">{escape(task_id)}</returnval>',
    )


async def _snapshot_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    name = _xml_text(body, "name") or "snapshot"
    _, task_id = await vm_ops.create_snapshot(database, vm, name=name)
    return _wrap(
        "CreateSnapshot_TaskResponse",
        f'<returnval type="Task">{escape(task_id)}</returnval>',
    )


async def _query_task(body: str, database: Database) -> str:
    from app.vsphere.domain import tasks as task_store

    task_id = _xml_text(body, "_this") or ""
    # Also accept obj refs in RetrieveProperties body
    if not task_id or not task_id.startswith("task-"):
        import re as _re

        m = _re.search(r'type="Task"[^>]*>([^<]+)<', body)
        if m:
            task_id = m.group(1)
    task = await task_store.get_task(database, task_id)
    if task is None:
        state = "error"
        description = "unknown"
        entity = ""
    else:
        state = {
            "SUCCEEDED": "success",
            "FAILED": "error",
            "RUNNING": "running",
            "PENDING": "queued",
        }.get(task["status"], "running")
        description = task.get("operation") or "task"
        entity = (task.get("result") or {}).get("vm") or ""
    entity_xml = (
        f'<propSet><name>info.entity</name><val xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
        f'xsi:type="ManagedObjectReference" type="VirtualMachine">{escape(entity)}</val></propSet>'
        if entity
        else ""
    )
    return _wrap(
        "RetrievePropertiesResponse",
        f"""<returnval>
          <obj type="Task">{escape(task_id)}</obj>
          <propSet><name>info.state</name><val>{state}</val></propSet>
          <propSet><name>info.descriptionId</name><val>{escape(description)}</val></propSet>
          <propSet><name>info.queueTime</name><val>{escape(str((task or {}).get("created_at") or ""))}</val></propSet>
          <propSet><name>info.completeTime</name><val>{escape(str((task or {}).get("completed_at") or ""))}</val></propSet>
          {entity_xml}
        </returnval>""",
    )


async def _rename_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import inventory_ops
    from app.vsphere.domain import tasks as task_store

    moid = _xml_text(body, "_this") or ""
    name = _xml_text(body, "newName") or _xml_text(body, "name") or moid
    await inventory_ops.rename_object(database, moid, name)
    task_id = await task_store.create_task(
        database,
        description=f"Rename {moid}",
        service="vim.ManagedEntity",
        operation="rename",
        result={"moid": moid, "name": name},
    )
    return _wrap("Rename_TaskResponse", f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _template_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    as_template = "MarkAsTemplate" in body
    task_id = await vm_ops.set_template(database, vm, template=as_template)
    tag = "MarkAsTemplateResponse" if as_template else "MarkAsVirtualMachineResponse"
    return _wrap(tag, f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _destroy_or_unregister(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    if "UnregisterVM" in body:
        task_id = await vm_ops.unregister_vm(database, vm)
        return _wrap("UnregisterVMResponse", "")
    # Destroy_Task — power off then delete
    obj = await inventory.get_object(database, vm)
    if obj and obj.props.get("power_state") == "POWERED_ON":
        await vm_ops.set_power(database, vm, "stop")
    await inventory.delete_object(database, vm)
    from app.vsphere.domain import tasks as task_store

    task_id = await task_store.create_task(
        database,
        description=f"Destroy {vm}",
        service="vim.ManagedEntity",
        operation="destroy",
        result={"vm": vm},
    )
    return _wrap("Destroy_TaskResponse", f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _reconfig_vm_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import tasks as task_store
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    obj = await inventory.get_object(database, vm)
    if obj is None:
        raise SoapFaultError("ManagedObjectNotFound", vm)
    props = dict(obj.props)
    num_cpu = _xml_text(body, "numCPUs") or _xml_text(body, "numCpu")
    memory = _xml_text(body, "memoryMB")
    if num_cpu:
        props["cpu_count"] = int(num_cpu)
    if memory:
        props["memory_size_mib"] = int(memory)
    # Parse deviceChange adds: VirtualDisk capacity + VirtualEthernetCard network.
    if "VirtualDisk" in body:
        capacities = re.findall(
            r"<(?:\w+:)?capacityInKB[^>]*>(\d+)</(?:\w+:)?capacityInKB>",
            body,
        )
        for cap_kb in capacities:
            capacity = int(cap_kb) * 1024
            # Prefer vm_ops when adding; avoid double-adding on pure edit.
            if "operation>add" in body.replace(" ", "") or "<operation>add</operation>" in body:
                await vm_ops.add_disk(database, vm, capacity)
                obj = await inventory.get_object(database, vm)
                props = dict(obj.props) if obj else props
            elif props.get("disks"):
                disks = list(props["disks"])
                value = dict(disks[0].get("value") or disks[0])
                value["capacity"] = capacity
                disks[0] = {**disks[0], "value": value} if "value" in disks[0] else value
                props["disks"] = disks
    if "VirtualEthernetCard" in body or "VirtualVmxnet3" in body:
        network = _xml_attr_or_text(body, "network")
        if network and (
            "<operation>add</operation>" in body or "operation>add" in body.replace(" ", "")
        ):
            await vm_ops.add_nic(database, vm, network)
            obj = await inventory.get_object(database, vm)
            props = dict(obj.props) if obj else props
        elif network:
            props["networks"] = [network]
            nics = list(props.get("nics") or [])
            if nics:
                value = dict(nics[0].get("value") or nics[0])
                backing = dict(value.get("backing") or {})
                backing["network"] = network
                value["backing"] = backing
                nics[0] = {**nics[0], "value": value} if "value" in nics[0] else value
                props["nics"] = nics
    name = _xml_text(body, "name")
    if name:
        await inventory.upsert_object(
            database,
            moid=vm,
            type_name="VirtualMachine",
            name=name,
            parent_moid=obj.parent_moid,
            props=props,
        )
    else:
        await inventory.update_props(database, vm, props)
    task_id = await task_store.create_task(
        database,
        description=f"Reconfig {vm}",
        service="vim.VirtualMachine",
        operation="reconfig",
        result={"vm": vm},
    )
    return _wrap("ReconfigVM_TaskResponse", f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _guest_file_ops(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    # Guest ops target GuestOperationsManager; vm MoRef often in <vm> element.
    vm = _xml_attr_or_text(body, "vm") or _xml_text(body, "_this") or ""
    if vm in {"guestOperationsManager", "GuestOperationsManager", "fileManager"}:
        vm = _xml_attr_or_text(body, "vm") or ""
    path = (
        _xml_text(body, "filePath")
        or _xml_text(body, "guestFilePath")
        or _xml_text(body, "path")
        or "/"
    )
    if "ListFilesInGuest" in body:
        entries = await vm_ops.guest_list_files(database, vm, path)
        items = "".join(
            f"<GuestListFileInfo><path>{escape(e['path'])}</path>"
            f"<type>{escape(str(e['type']))}</type>"
            f"<size>{int(e.get('size') or 0)}</size></GuestListFileInfo>"
            for e in entries
        )
        return _wrap("ListFilesInGuestResponse", f"<returnval>{items}</returnval>")
    if "InitiateFileTransferToGuest" in body:
        content = _xml_text(body, "content") or ""
        await vm_ops.guest_write_file(database, vm, path, content or f"lab-upload:{path}")
        url = f"https://localhost/api/vcenter/vm/{vm}/guest/filesystem?path={path}"
        return _wrap("InitiateFileTransferToGuestResponse", f"<returnval>{escape(url)}</returnval>")
    if "InitiateFileTransferFromGuest" in body:
        url = f"https://localhost/api/vcenter/vm/{vm}/guest/filesystem?path={path}"
        return _wrap(
            "InitiateFileTransferFromGuestResponse",
            f"<returnval><url>{escape(url)}</url></returnval>",
        )
    if "DeleteFileInGuest" in body:
        await vm_ops.guest_delete_file(database, vm, path)
        return _empty_response("DeleteFileInGuestResponse")
    if "MakeDirectoryInGuest" in body:
        await vm_ops.guest_write_file(database, vm, path.rstrip("/") + "/.keep", "")
        return _empty_response("MakeDirectoryInGuestResponse")
    return _empty_response("MethodFaultResponse")


async def _nfc_lease_ops(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import platform_surface
    from app.vsphere.domain import tasks as task_store

    if "CreateImportSpec" in body:
        return _wrap(
            "CreateImportSpecResponse",
            """<returnval>
              <importSpec xsi:type="VirtualMachineImportSpec" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
                <configSpec><name>imported-lab-vm</name><guestId>otherGuest64</guestId>
                <numCPUs>1</numCPUs><memoryMB>1024</memoryMB></configSpec>
              </importSpec>
              <warning></warning>
            </returnval>""",
        )
    if "ImportVApp_Task" in body:
        # VIM: _this is ResourcePool; folder/host are separate args.
        name = _xml_text(body, "name") or "imported-lab-vm"
        folder = _xml_text(body, "folder") or "group-v23"
        pool = _xml_text(body, "_this") or "resgroup-22"
        host = _xml_text(body, "host") or "host-11"
        from app.vsphere.domain import vm_ops

        moid, _create_task = await vm_ops.create_vm(
            database,
            name=name,
            folder=folder,
            host=host,
            resource_pool=pool,
            guest_os="OTHER_GUEST_64",
            cpu_count=1,
            memory_size_mib=1024,
        )
        lease = await platform_surface.create_nfc_lease(
            database, vm=moid, files=[f"{name}.vmdk", f"{name}.nvram"]
        )
        task_id = await task_store.create_task(
            database,
            description=f"ImportVApp {moid}",
            service="vim.ResourcePool",
            operation="import_vapp",
            result={"vm": moid, "lease": lease["lease"]},
        )
        return _wrap(
            "ImportVApp_TaskResponse",
            f'<returnval type="Task">{escape(task_id)}</returnval>',
        )
    lease_id = _xml_text(body, "_this") or ""
    lease = await platform_surface.get_nfc_lease(database, lease_id)
    if "HttpNfcLeaseComplete" in body:
        await platform_surface.complete_nfc_lease(database, lease_id)
        return _empty_response("HttpNfcLeaseCompleteResponse")
    if "HttpNfcLeaseAbort" in body:
        if lease:
            lease["state"] = "error"
            await platform_surface._save_nfc_lease(database, lease_id, lease)
        return _empty_response("HttpNfcLeaseAbortResponse")
    if "HttpNfcLeaseProgress" in body:
        percent = int(_xml_text(body, "percent") or "100")
        if lease:
            lease["transferProgress"] = percent
            await platform_surface._save_nfc_lease(database, lease_id, lease)
        return _empty_response("HttpNfcLeaseProgressResponse")
    if "HttpNfcLeaseGetManifest" in body or lease is not None:
        files_xml = ""
        if lease:
            for name, meta in (lease.get("files") or {}).items():
                files_xml += (
                    f"<HttpNfcLeaseManifestEntry><key>{escape(name)}</key>"
                    f"<size>{int(meta.get('size') or 0)}</size></HttpNfcLeaseManifestEntry>"
                )
        return _wrap("HttpNfcLeaseGetManifestResponse", f"<returnval>{files_xml}</returnval>")
    return _empty_response("MethodFaultResponse")


async def _relocate_task(body: str, request: Request, database: Database) -> str:
    await _require_soap_session(request, database)
    from app.vsphere.domain import vm_ops

    vm = _xml_text(body, "_this") or ""
    host = _xml_text(body, "host")
    datastore = _xml_text(body, "datastore")
    task_id = await vm_ops.relocate_vm(database, vm, host=host, datastore=datastore)
    tag = "MigrateVM_TaskResponse" if "MigrateVM_Task" in body else "RelocateVM_TaskResponse"
    return _wrap(tag, f'<returnval type="Task">{escape(task_id)}</returnval>')


async def _find_by_path(body: str, database: Database) -> str:
    from app.vsphere.soap import property_collector as pc

    path = _xml_text(body, "inventoryPath") or ""
    objects = await inventory.list_objects(database)
    obj = pc.resolve_inventory_path(path, objects)
    if obj is None:
        return _wrap("FindByInventoryPathResponse", "")
    return _wrap(
        "FindByInventoryPathResponse",
        f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
    )


async def _find_child(body: str, database: Database) -> str:
    from app.vsphere.soap import property_collector as pc

    parent = _xml_attr_or_text(body, "entity") or _xml_text(body, "_this") or ""
    name = _xml_text(body, "name") or ""
    objects = await inventory.list_objects(database)
    obj = pc.find_child(parent, name, objects)
    if obj is None:
        return _wrap("FindChildResponse", "")
    return _wrap(
        "FindChildResponse",
        f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
    )


def _parent_type(moid: str) -> str:
    if moid.startswith("group-"):
        return "Folder"
    if moid.startswith("datacenter-"):
        return "Datacenter"
    if moid.startswith("domain-"):
        return "ClusterComputeResource"
    if moid.startswith("host-"):
        return "HostSystem"
    return "ManagedEntity"


def _vim_power(state: str) -> str:
    return {
        "POWERED_ON": "poweredOn",
        "POWERED_OFF": "poweredOff",
        "SUSPENDED": "suspended",
    }.get(state, "poweredOff")


def _prop(name: str, value: str) -> str:
    return f"<propSet><name>{escape(name)}</name><val>{escape(value)}</val></propSet>"


def _service_content_response() -> str:
    return _wrap(
        "RetrieveServiceContentResponse",
        f"""<returnval>
      <rootFolder type="Folder">group-d1</rootFolder>
      <propertyCollector type="PropertyCollector">propertyCollector</propertyCollector>
      <viewManager type="ViewManager">ViewManager</viewManager>
      <about>
        <name>VMware vCenter Server</name>
        <fullName>VMware vCenter Server {VIM_VERSION} simulator</fullName>
        <vendor>VMware, Inc.</vendor>
        <version>{VIM_VERSION}</version>
        <build>22361780</build>
        <localeVersion>INTL</localeVersion>
        <localeBuild>000</localeBuild>
        <osType>linux-x64</osType>
        <productLineId>vpx</productLineId>
        <apiType>VirtualCenter</apiType>
        <apiVersion>{VIM_VERSION}</apiVersion>
        <instanceUuid>5029aaaa-bbbb-cccc-dddd-eeeeeeeeeeee</instanceUuid>
        <licenseProductName>VMware VirtualCenter Server</licenseProductName>
        <licenseProductVersion>8.0</licenseProductVersion>
      </about>
      <sessionManager type="SessionManager">SessionManager</sessionManager>
      <authorizationManager type="AuthorizationManager">AuthorizationManager</authorizationManager>
      <searchIndex type="SearchIndex">SearchIndex</searchIndex>
      <eventManager type="EventManager">EventManager</eventManager>
      <taskManager type="TaskManager">TaskManager</taskManager>
      <guestOperationsManager type="GuestOperationsManager">guestOperationsManager</guestOperationsManager>
      <fileManager type="FileManager">FileManager</fileManager>
      <virtualDiskManager type="VirtualDiskManager">VirtualDiskManager</virtualDiskManager>
      <ovfManager type="OvfManager">OvfManager</ovfManager>
      <ipPoolManager type="IpPoolManager">IpPoolManager</ipPoolManager>
      <customizationSpecManager type="CustomizationSpecManager">CustomizationSpecManager</customizationSpecManager>
    </returnval>""",
    )


def _empty_response(tag: str) -> str:
    return _wrap(tag, "")


def _wrap(response_tag: str, inner: str) -> str:
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="{NS_SOAP}">
  <soapenv:Body>
    <{response_tag} xmlns="urn:vim25">
      {inner}
    </{response_tag}>
  </soapenv:Body>
</soapenv:Envelope>
"""


def _fault_envelope(fault: SoapFaultError) -> str:
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="{NS_SOAP}">
  <soapenv:Body>
    <soapenv:Fault>
      <faultcode>{escape(fault.fault_code)}</faultcode>
      <faultstring>{escape(fault.message)}</faultstring>
    </soapenv:Fault>
  </soapenv:Body>
</soapenv:Envelope>
"""


def _xml_text(body: str, tag: str) -> str | None:
    match = re.search(
        rf"<(?:\w+:)?{re.escape(tag)}[^>]*>([^<]*)</(?:\w+:)?{re.escape(tag)}>",
        body,
    )
    return match.group(1) if match else None


def _xml_attr_or_text(body: str, tag: str) -> str | None:
    """Read MOR-style tags: <host type="HostSystem">host-11</host> or plain text."""

    match = re.search(
        rf"<(?:\w+:)?{re.escape(tag)}\b[^>]*>([^<]*)</(?:\w+:)?{re.escape(tag)}>",
        body,
    )
    if match and match.group(1).strip():
        return match.group(1).strip()
    return None


def _rest_guest_from_vim(guest_id: str) -> str:
    mapping = {
        "ubuntu64Guest": "UBUNTU_64_GUEST",
        "centos64Guest": "CENTOS_64_GUEST",
        "rhel8_64Guest": "RHEL_8_64_GUEST",
        "windows2019srv_64Guest": "WINDOWS_2019_64_GUEST",
        "otherGuest64": "OTHER_GUEST_64",
        "otherGuest": "OTHER_GUEST",
    }
    return mapping.get(guest_id, guest_id.upper() if guest_id.islower() else guest_id)
