# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Run My Accounts (Infoniqa) accounting connector.

API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/
Auth: PAT tokens (``pat_...``) via ``Authorization: Bearer``.
Fallback for legacy API keys via ``X-RMA-KEY``.
Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/
"""

import asyncio
import json
import logging
import re
from datetime import datetime
from typing import List, Dict, Any, Optional

import aiohttp

from ..accountingConnectorBase import (
    BaseAccountingConnector,
    AccountingBooking,
    AccountingChart,
    ConnectorConfigField,
    SyncResult,
)
from modules.shared.i18nRegistry import t

logger = logging.getLogger(__name__)

_DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/"


class AccountingConnectorRma(BaseAccountingConnector):

    def getConnectorType(self) -> str:
        return "rma"

    def getConnectorLabel(self) -> str:
        return "Run My Accounts"

    def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
        return [
            ConnectorConfigField(
                key="apiBaseUrl",
                label=t("API Base URL"),
                fieldType="text",
                secret=False,
                placeholder="https://service.runmyaccounts.com/api/latest/clients/",
            ),
            ConnectorConfigField(
                key="clientName",
                label=t("Mandantenname"),
                fieldType="text",
                secret=False,
                placeholder="e.g. meinefirma",
            ),
            ConnectorConfigField(
                key="apiKey",
                label=t("API-Schlüssel"),
                fieldType="password",
                secret=True,
            ),
        ]

    def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
        apiBaseUrl = str(config.get("apiBaseUrl") or "").strip()
        if not apiBaseUrl:
            raise ValueError("Missing required config: apiBaseUrl")
        apiBaseUrl = apiBaseUrl.rstrip("/") + "/"

        clientName = str(config.get("clientName") or "").strip()
        if not clientName:
            raise ValueError("Missing required config: clientName")
        return f"{apiBaseUrl}{clientName}/{resource}"

    def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
        apiKey = config.get("apiKey", "")
        headers = {
            "Accept": "application/json, application/xml, */*",
            "Content-Type": "application/json",
        }
        if str(apiKey).startswith("pat_"):
            headers["Authorization"] = f"Bearer {apiKey}"
        else:
            headers["X-RMA-KEY"] = apiKey
        return headers

    async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
        clientName = config.get("clientName", "")
        apiKey = config.get("apiKey", "")

        apiBaseUrl = str(config.get("apiBaseUrl") or "")
        if not clientName or not apiKey or not apiBaseUrl:
            return SyncResult(
                success=False,
                errorMessage=(
                    f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, "
                    f"clientName={bool(clientName)}, apiKey={bool(apiKey)}"
                ),
            )

        url = self._buildUrl(config, "customers")
        headers = self._buildHeaders(config)
        authMethod = "Bearer" if str(apiKey).startswith("pat_") else "X-RMA-KEY"
        logger.info(
            "RMA testConnection: url=%s, clientName=%s, apiKey=%s..., auth=%s",
            url, clientName, apiKey[:6] if len(apiKey) > 6 else "***", authMethod,
        )
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status == 200:
                        logger.info("RMA connection successful with auth method: %s", authMethod)
                        return SyncResult(success=True, rawResponse={"authMethod": authMethod})
                    body = await resp.text()
                    logger.warning("RMA testConnection failed: status=%s, url=%s, body=%s", resp.status, url, body[:500])
                    return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
        except Exception as e:
            return SyncResult(success=False, errorMessage=str(e))

    def _rmaLinkToAccountType(self, link: str) -> str:
        """Map RMA chart 'link' (e.g. AP_amount, AR_amount, AR_paid:AP_paid) to our accountType."""
        if not link:
            return ""
        linkUpper = link.upper()
        if "AP_AMOUNT" in linkUpper:
            return "expense"
        if "AR_AMOUNT" in linkUpper:
            return "revenue"
        if "AR_PAID" in linkUpper or "AP_PAID" in linkUpper:
            return "asset"
        if "AR_TAX" in linkUpper or "AP_TAX" in linkUpper:
            return "liability"
        if linkUpper in ("AR", "AP"):
            return "asset"
        return link

    async def getChartOfAccounts(self, config: Dict[str, Any], accountType: Optional[str] = None) -> List[AccountingChart]:
        """RMA API 'type' filter expects RMA values (AP_amount, AR_amount, etc.), not 'expense'. Fetch full chart and filter client-side."""
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "charts")
                async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    if resp.status != 200:
                        body = await resp.text()
                        logger.error(f"RMA charts failed: HTTP {resp.status} - {body[:200]}")
                        return []
                    data = await resp.json()

            charts = []
            items = data if isinstance(data, list) else data.get("chart", data.get("row", []))
            if not isinstance(items, list):
                items = [items] if isinstance(items, dict) else []
            for item in items:
                if isinstance(item, dict):
                    accNo = str(item.get("accno") or item.get("account_number") or item.get("number") or item.get("@accno") or "")
                    label = str(item.get("description") or item.get("label") or item.get("@description") or "")
                    rmaLink = item.get("link") or ""
                    chartType = item.get("charttype") or item.get("category") or ""
                    if not chartType and rmaLink:
                        chartType = self._rmaLinkToAccountType(rmaLink)
                    if not chartType and accNo:
                        firstDigit = accNo[0] if accNo else ""
                        chartType = {
                            "1": "asset", "2": "liability", "3": "revenue",
                            "4": "expense", "5": "expense", "6": "expense",
                            "7": "expense", "8": "expense", "9": "closing",
                        }.get(firstDigit, "")
                    charts.append(AccountingChart(accountNumber=accNo, label=label, accountType=chartType))
            return charts
        except Exception as e:
            logger.error(f"RMA getChartOfAccounts error: {e}")
            return []

    async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
        """Push a GL batch booking to RMA. API expects request body = batch object (no wrapper); only non-zero amount per line."""
        try:
            if not booking.lines:
                return SyncResult(success=False, errorMessage="Missing transactions in batch (no booking lines)")

            glTransactions = []
            for line in booking.lines:
                t = {"accno": line.accountNumber}
                if line.debitAmount and line.debitAmount > 0:
                    t["debit_amount"] = line.debitAmount
                if line.creditAmount and line.creditAmount > 0:
                    t["credit_amount"] = line.creditAmount
                if line.description:
                    t["memo"] = (line.description or "")[:500]
                glTransactions.append(t)

            transdate = booking.bookingDate or ""
            if transdate and "T" not in str(transdate):
                transdate = f"{transdate}T00:00:00.000+00:00"
            batchNumber = (booking.reference or "").strip()[:32]
            if not batchNumber:
                batchNumber = "GL-" + (booking.reference or str(id(booking)))[:24]
            rawDesc = (booking.description or "").strip()
            externalDocIds = getattr(booking, "externalDocumentIds", None) or []
            externalDocLabels = getattr(booking, "externalDocumentLabels", None) or []
            if externalDocIds or externalDocLabels:
                clientSegment = (config.get("clientId") or config.get("clientName") or "").strip()
                docParts = []
                maxI = max(len(externalDocIds), len(externalDocLabels))
                for i in range(min(maxI, 10)):
                    label = (externalDocLabels[i] if i < len(externalDocLabels) else "Rechnung") or "Rechnung"
                    belegId = externalDocIds[i] if i < len(externalDocIds) else None
                    if belegId:
                        docUrl = f"https://my.runmyaccounts.com/phoenix-core/api/clients/{clientSegment}/workflow/l0doc/file/{belegId}"
                        docParts.append(f'<a href="{docUrl}">{label}</a>')
                    else:
                        docParts.append(label)
                erfDate = datetime.utcnow().strftime("%d.%m.%Y")
                linkSuffix = " (" + ", ".join(docParts) + ", erf. " + erfDate + ")"
                shortDesc = (rawDesc[:80] + "...") if len(rawDesc) > 80 else rawDesc
                description = (shortDesc + linkSuffix).strip()[:500]
            else:
                description = rawDesc[:500]
            payload = {
                "batch_number": batchNumber,
                "transdate": transdate,
                "description": description,
                "currency": "CHF",
                "exchangerate": 1.0,
                "gl_transactions": {"gl_transaction": glTransactions},
            }
            if rawDesc and len(rawDesc) > 80:
                payload["notes"] = rawDesc[:2000]

            logger.debug("RMA pushBooking payload: batch=%s transdate=%s accounts=%s",
                         batchNumber, transdate,
                         [(t.get("accno"), t.get("debit_amount"), t.get("credit_amount")) for t in glTransactions])

            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "gl")
                async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    body = await resp.text()
                    if resp.status in (200, 201, 204):
                        externalId = None
                        try:
                            data = json.loads(body) if body.strip() else {}
                            if isinstance(data, dict):
                                externalId = (
                                    data.get("id")
                                    or data.get("batch_id")
                                    or data.get("entry_id")
                                    or (data.get("gl_batch") or {}).get("id")
                                )
                                if externalId is not None:
                                    externalId = str(externalId).strip()
                            if not externalId:
                                externalId = batchNumber
                        except Exception:
                            externalId = batchNumber
                        return SyncResult(
                            success=True,
                            externalId=externalId or batchNumber,
                            rawResponse={"status": resp.status, "body": body[:500]},
                        )
                    errMsg = f"HTTP {resp.status}: {body[:300]}"
                    logger.error("RMA pushBooking failed: status=%s, body=%s", resp.status, body[:500])
                    return SyncResult(success=False, errorMessage=errMsg)
        except asyncio.TimeoutError:
            logger.warning("RMA pushBooking timeout (30s)")
            return SyncResult(
                success=False,
                errorMessage="Verbindung zum Buchhaltungssystem hat zu lange gedauert (Timeout). Bitte später erneut versuchen.",
            )
        except Exception as e:
            logger.exception("RMA pushBooking error")
            return SyncResult(success=False, errorMessage=str(e))

    async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, f"charts/{externalId}/transactions")
                async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status == 200:
                        return SyncResult(success=True, externalId=externalId)
                    return SyncResult(success=False, errorMessage=f"HTTP {resp.status}")
        except Exception as e:
            return SyncResult(success=False, errorMessage=str(e))

    async def getBookingByExternalId(self, config: Dict[str, Any], externalId: str) -> SyncResult:
        """Check if the GL batch/transaction still exists in RMA by external ID (id or batch_number)."""
        if not externalId or not str(externalId).strip():
            return SyncResult(success=False, errorMessage="Not found")
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, f"gl/{externalId.strip()}")
                async with session.get(
                    url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)
                ) as resp:
                    if resp.status == 200:
                        return SyncResult(success=True, externalId=externalId)
                    if resp.status == 404:
                        return SyncResult(success=False, errorMessage="Not found")
                    body = await resp.text()
                    return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
        except Exception as e:
            logger.debug("RMA getBookingByExternalId failed: %s", e)
            return SyncResult(success=False, errorMessage=str(e))

    async def isBookingSynced(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
        """Check if booking exists in RMA by searching transactions for reference match.
        Uses GET charts/{accno}/transactions with date filter. success=True = booking exists."""
        if not booking.lines:
            return SyncResult(success=True)
        ref = (booking.reference or "").strip()[:32]
        if not ref:
            return SyncResult(success=True)
        fromDate = (booking.bookingDate or "").split("T")[0].strip()[:10]
        if not fromDate or len(fromDate) < 10:
            return SyncResult(success=True)
        accountNumbers = list({ln.accountNumber for ln in booking.lines if ln.accountNumber})
        if not accountNumbers:
            return SyncResult(success=True)
        try:
            async with aiohttp.ClientSession() as session:
                for accno in accountNumbers:
                    url = self._buildUrl(config, f"charts/{accno}/transactions")
                    params = {"from_date": fromDate, "to_date": fromDate}
                    async with session.get(
                        url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=15)
                    ) as resp:
                        if resp.status != 200:
                            continue
                        data = await resp.json()
                    transactions = data.get("transaction") if isinstance(data, dict) else []
                    if isinstance(data, list):
                        transactions = data
                    if not isinstance(transactions, list):
                        transactions = [transactions] if isinstance(transactions, dict) else []
                    for t in transactions:
                        if isinstance(t, dict) and (t.get("reference") or "").strip() == ref:
                            return SyncResult(success=True)
            return SyncResult(success=False, errorMessage="Reference not found in RMA transactions")
        except asyncio.TimeoutError:
            logger.debug("RMA isBookingSynced timeout – trust local")
            return SyncResult(success=True)
        except Exception as e:
            logger.debug("RMA isBookingSynced error: %s – trust local", e)
            return SyncResult(success=True)

    async def getJournalEntries(self, config: Dict[str, Any], dateFrom: Optional[str] = None, dateTo: Optional[str] = None, accountNumbers: Optional[List[str]] = None) -> List[Dict[str, Any]]:
        """Read GL entries from RMA.

        Strategy: first try GET /gl (bulk), then fall back to iterating
        account transactions. Uses pre-fetched accountNumbers if provided.
        """
        try:
            params: Dict[str, str] = {}
            if dateFrom:
                params["from_date"] = dateFrom
            if dateTo:
                params["to_date"] = dateTo

            # Try bulk GL endpoint first
            bulkEntries = await self._fetchGlBulk(config, params)
            if bulkEntries:
                return bulkEntries

            # Fallback: iterate accounts and fetch transactions
            if accountNumbers:
                accNums = accountNumbers
            else:
                chart = await self.getChartOfAccounts(config)
                accNums = [acc.accountNumber for acc in chart if acc.accountNumber]
            if not accNums:
                return []

            entriesByRef: Dict[str, Dict[str, Any]] = {}
            fetchedCount = 0
            emptyCount = 0
            errorCount = 0
            async with aiohttp.ClientSession() as session:
                for accNo in accNums:
                    url = self._buildUrl(config, f"charts/{accNo}/transactions")
                    try:
                        async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
                            if resp.status != 200:
                                emptyCount += 1
                                continue
                            body = await resp.text()
                            if not body.strip():
                                emptyCount += 1
                                continue
                            try:
                                data = json.loads(body)
                            except Exception:
                                errorCount += 1
                                continue
                    except (asyncio.TimeoutError, Exception):
                        errorCount += 1
                        continue
                    fetchedCount += 1

                    if isinstance(data, dict):
                        transactions = data.get("transaction") or data.get("@transaction")
                    else:
                        transactions = data
                    if isinstance(transactions, dict):
                        transactions = [transactions]
                    if not isinstance(transactions, list):
                        continue

                    for t in transactions:
                        if not isinstance(t, dict):
                            continue
                        ref = t.get("reference") or t.get("@reference") or t.get("batch_number") or str(t.get("id") or "")
                        transDate = str(t.get("transdate") or t.get("@transdate") or "").split("T")[0]
                        desc = t.get("description") or t.get("memo") or t.get("@description") or ""

                        rawAmount = float(t.get("amount") or t.get("@amount") or 0)
                        debit = rawAmount if rawAmount > 0 else 0.0
                        credit = abs(rawAmount) if rawAmount < 0 else 0.0

                        if ref not in entriesByRef:
                            entriesByRef[ref] = {
                                "externalId": str(t.get("id") or t.get("@id") or ref),
                                "bookingDate": transDate,
                                "reference": ref,
                                "description": desc,
                                "currency": "CHF",
                                "totalAmount": 0.0,
                                "lines": [],
                            }
                        entry = entriesByRef[ref]
                        entry["lines"].append({
                            "accountNumber": accNo,
                            "debitAmount": debit,
                            "creditAmount": credit,
                            "description": desc,
                        })
                        entry["totalAmount"] += max(debit, credit)

            return list(entriesByRef.values())
        except Exception as e:
            logger.error(f"RMA getJournalEntries error: {e}", exc_info=True)
            return []

    async def _fetchGlBulk(self, config: Dict[str, Any], params: Dict[str, str]) -> List[Dict[str, Any]]:
        """Try GET /gl to fetch journal entries in bulk (not all RMA versions support this)."""
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "gl")
                async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
                    if resp.status != 200:
                        return []
                    body = await resp.text()
                    if not body.strip():
                        return []
                    try:
                        data = json.loads(body)
                    except Exception:
                        return []

            items = data if isinstance(data, list) else (data.get("gl_batch") or data.get("gl") or data.get("items") or [])
            if isinstance(items, dict):
                items = [items]
            if not isinstance(items, list):
                return []

            entries = []
            for batch in items:
                if not isinstance(batch, dict):
                    continue
                transDate = str(batch.get("transdate") or batch.get("date") or "").split("T")[0]
                ref = batch.get("batch_number") or batch.get("reference") or str(batch.get("id", ""))
                desc = batch.get("description") or batch.get("notes") or ""

                rawTxns = batch.get("gl_transactions", {})
                txnList = rawTxns.get("gl_transaction") if isinstance(rawTxns, dict) else rawTxns
                if isinstance(txnList, dict):
                    txnList = [txnList]
                if not isinstance(txnList, list):
                    txnList = []

                lines = []
                totalAmt = 0.0
                for t in txnList:
                    if not isinstance(t, dict):
                        continue
                    debit = float(t.get("debit_amount") or 0)
                    credit = float(t.get("credit_amount") or 0)
                    lines.append({
                        "accountNumber": str(t.get("accno", "")),
                        "debitAmount": debit,
                        "creditAmount": credit,
                        "description": t.get("memo", ""),
                    })
                    totalAmt += max(debit, credit)

                entries.append({
                    "externalId": str(batch.get("id", ref)),
                    "bookingDate": transDate,
                    "reference": ref,
                    "description": desc,
                    "currency": batch.get("currency", "CHF"),
                    "totalAmount": totalAmt,
                    "lines": lines,
                })
            return entries
        except Exception as e:
            logger.debug(f"RMA _fetchGlBulk not available: {e}")
            return []

    async def pushInvoice(self, config: Dict[str, Any], invoice: Dict[str, Any]) -> SyncResult:
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "invoices")
                async with session.post(url, headers=self._buildHeaders(config), json=invoice, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    body = await resp.text()
                    if resp.status in (200, 201):
                        return SyncResult(success=True, rawResponse={"body": body[:500]})
                    return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
        except Exception as e:
            return SyncResult(success=False, errorMessage=str(e))

    async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "customers")
                async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    if resp.status != 200:
                        return []
                    data = await self._parseJsonOrXmlList(resp, "customer")
                    return data
        except Exception as e:
            logger.error(f"RMA getCustomers error: {e}")
            return []

    async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
        try:
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "vendors")
                async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    if resp.status != 200:
                        return []
                    data = await self._parseJsonOrXmlList(resp, "vendor")
                    return data
        except Exception as e:
            logger.error(f"RMA getVendors error: {e}")
            return []

    async def _parseJsonOrXmlList(self, resp: aiohttp.ClientResponse, itemKey: str) -> List[Dict[str, Any]]:
        """Parse RMA response that may be JSON or XML. Returns list of dicts."""
        body = await resp.text()
        if not body or not body.strip():
            return []
        try:
            data = json.loads(body)
            if isinstance(data, list):
                return data
            if isinstance(data, dict):
                items = data.get(itemKey) or data.get("items") or data.get("row") or []
                if isinstance(items, dict):
                    return [items]
                return items if isinstance(items, list) else []
            return []
        except (json.JSONDecodeError, ValueError):
            pass
        result: List[Dict[str, Any]] = []
        ids = re.findall(r"<id>([^<]+)</id>", body)
        names = re.findall(r"<name>([^<]+)</name>", body)
        for i, rid in enumerate(ids):
            entry: Dict[str, Any] = {"id": rid.strip()}
            if i < len(names):
                entry["name"] = names[i].strip()
            result.append(entry)
        return result

    async def _findBelegByFilename(self, config: Dict[str, Any], session: aiohttp.ClientSession, fileName: str) -> Optional[str]:
        """Try GET /belege (undocumented) to find an existing beleg by filename."""
        try:
            url = self._buildUrl(config, "belege")
            async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
                body = await resp.text()
                logger.info("RMA GET /belege: status=%s, body=%s", resp.status, body[:1000])
                if resp.status != 200:
                    return None
                try:
                    data = json.loads(body)
                except Exception:
                    data = None
                if isinstance(data, list):
                    items = data
                elif isinstance(data, dict):
                    items = data.get("beleg_upload") or data.get("belege") or data.get("row") or []
                    if isinstance(items, dict):
                        items = [items]
                else:
                    # Try XML: extract all <id> + <file_name> pairs
                    ids = re.findall(r"<id>([^<]+)</id>", body)
                    names = re.findall(r"<file_name>([^<]+)</file_name>", body)
                    for bid, fname in zip(ids, names):
                        if fileName.lower() in fname.lower():
                            logger.info("RMA GET /belege: matched filename=%s → belegId=%s", fname, bid)
                            return bid.strip()
                    return None
                if not isinstance(items, list):
                    return None
                for item in items:
                    if not isinstance(item, dict):
                        continue
                    fname = item.get("file_name") or item.get("fileName") or ""
                    if fileName.lower() in fname.lower():
                        bid = item.get("id")
                        if bid:
                            logger.info("RMA GET /belege: matched filename=%s → belegId=%s", fname, bid)
                            return str(bid).strip()
        except Exception as e:
            logger.debug("RMA _findBelegByFilename failed: %s", e)
        return None

    def _parseExistingBelegId(self, body: str) -> Optional[str]:
        """Try to extract the existing Beleg-ID from a 409 duplicate response. Tries multiple patterns."""
        if not (body or "").strip():
            return None
        # Pattern: "id":12345 or "id":"12345" in JSON
        match = re.search(r'"id"\s*:\s*"?(\d+)"?', body)
        if match:
            return match.group(1).strip()
        # Pattern: <id>12345</id> in XML
        match = re.search(r"<id>([^<]+)</id>", body)
        if match:
            return match.group(1).strip()
        # Pattern: numeric id in URL like /file/12345
        match = re.search(r"/file/(\d+)", body)
        if match:
            return match.group(1).strip()
        return None

    def _parseBelegIdFromResponse(self, body: str) -> Optional[str]:
        """Extract Beleg-ID from RMA POST /belege response (XML or JSON)."""
        if not (body or "").strip():
            return None
        try:
            data = json.loads(body)
            if isinstance(data, dict):
                bid = data.get("id") or (data.get("beleg_upload") or {}).get("id")
                if bid is not None:
                    return str(bid).strip()
        except Exception:
            pass
        match = re.search(r"<id>([^<]+)</id>", body)
        if match:
            return match.group(1).strip()
        return None

    async def uploadDocument(
        self,
        config: Dict[str, Any],
        fileName: str,
        fileContent: bytes,
        mimeType: str = "application/pdf",
        comment: Optional[str] = None,
    ) -> SyncResult:
        """Upload a receipt via POST /belege (multipart/form-data). Returns SyncResult with externalId = Beleg-ID when present."""
        try:
            formData = aiohttp.FormData()
            formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType)
            if comment:
                formData.add_field("comment", comment[:500])

            headers = self._buildHeaders(config)
            headers.pop("Content-Type", None)  # let aiohttp set multipart boundary
            headers["Accept"] = "*/*"  # RMA may return XML on error; avoid 406 Not Acceptable
            async with aiohttp.ClientSession() as session:
                url = self._buildUrl(config, "belege")
                async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp:
                    body = await resp.text()
                    belegId = self._parseBelegIdFromResponse(body)
                    if resp.status in (200, 201):
                        return SyncResult(
                            success=True,
                            externalId=belegId,
                            rawResponse={"body": body[:500]},
                        )
                    if resp.status == 409:
                        logger.info("RMA uploadDocument 409 (duplicate), body=%s", body[:500])
                        if not belegId:
                            belegId = self._parseExistingBelegId(body)
                        if not belegId:
                            belegId = await self._findBelegByFilename(config, session, fileName)
                        return SyncResult(
                            success=True,
                            externalId=belegId,
                            rawResponse={"body": body[:500]},
                        )
                    errMsg = f"HTTP {resp.status}: {body[:300]}"
                    logger.error("RMA uploadDocument failed: status=%s, body=%s", resp.status, body[:500])
                    return SyncResult(success=False, errorMessage=errMsg)
        except Exception as e:
            logger.exception("RMA uploadDocument error")
            return SyncResult(success=False, errorMessage=str(e))
