"""Cross-Skill-Bridge zum getmyinvoices-Bestandsskill.

Importiert ``GmiClient`` und ``GmiHttpError`` aus dem Bundle-Skill
``getmyinvoices`` via ``_lib/cross_skill.import_helper`` und kapselt die
Operationen, die gmi-accountant braucht. Der Quell-Skill wird hier nur
GELESEN und über seine öffentliche API genutzt, niemals modifiziert.

Sicherheitsregeln (verbatim aus SKILL.md Sekret-Hygiene):

- ``GETMYINVOICES_API_KEY`` niemals in stdout, Logs oder Exceptions.
- ``fileContent`` (base64-PDF) niemals in Returns, Dicts oder LLM-Kontext.
- Download-Bytes wandern direkt auf Disk, nicht durch Python-Returns.

Bundle-Standard ab v0.10.0: Härtung gegen Rate-Limit-Burst-Amplifikation
über ein einheitliches Backoff-Schema (15s, 60s, 180s) plus 0.4s
Pre-Call-Pause. Die Rate-Limit-Konstanten stammen aus den dokumentierten
GMI-API-Limits (60 requests/min global, 5 downloads/document/day).
"""
from __future__ import annotations

import sys
import time
from pathlib import Path
from typing import Any, Callable, Dict, Iterator, List, Optional, TypeVar


# Skill-Verzeichnis und Bundle-Root ermitteln, damit der _lib-Import auch
# aus Skript-Kontext klappt.
_SKILL_DIR = Path(__file__).resolve().parent
_SKILLS_ROOT = _SKILL_DIR.parent
if str(_SKILLS_ROOT) not in sys.path:
    sys.path.insert(0, str(_SKILLS_ROOT))

from _lib.cross_skill import import_helper  # noqa: E402


def _resolve_bundle_root() -> Optional[Path]:
    """Liefert den Bundle-Root für den Cross-Skill-Import mit Path-Hygiene.

    Reihenfolge:

    1. Env-Var ``BUNDLE_ROOT`` (für Eval-Tests in isolierten tmp-Verzeichnissen).
       Wird via ``_lib/env_loader.require_path_env_var`` resolved und gegen
       Containment geprüft (Wave-1-Lesson 10: Path-Traversal-Schutz).
    2. Default ``None`` — ``import_helper`` leitet den Root selbst aus der
       Position von ``_lib/cross_skill.py`` ab.

    Sicherheits-Containment für ``BUNDLE_ROOT`` ist absichtlich offen gehalten
    (``allowed_roots=None``), weil Tests legitime tmp_paths injizieren und
    das produktive Single-User-Setup nur die eigene Maschine berührt. Wenn
    dieser Skill je in einen Mehrnutzer-Kontext kommt, kommen erlaubte
    Wurzeln via ``GMI_ACCOUNTANT_ALLOWED_BUNDLE_ROOTS`` als Komma-Liste hinzu.
    Die Pflicht-Härtung (``resolve()`` + ``must_exist``) ist auch ohne
    Containment-Liste aktiv und neutralisiert ``..``-Traversal.
    """
    from _lib.env_loader import require_path_env_var, EnvVarMissingError  # noqa: WPS433

    try:
        return require_path_env_var(
            "BUNDLE_ROOT",
            "gmi-accountant",
            allowed_roots=None,
            must_exist=True,
        )
    except EnvVarMissingError:
        return None


def _load_gmi_client_module():
    """Lädt das gmi_client-Modul des getmyinvoices-Skills.

    Sucht ``<bundle_root>/.skills/getmyinvoices/scripts/gmi_client.py`` —
    das Bundle-Layout von getmyinvoices hat den Client unter ``scripts/``.
    Cross-Skill-Resolver kennt seit v0.10.0 einen ``subpath``-Parameter, der
    genau diesen Layout-Fall abdeckt (vor v0.10.0 brauchten Konsumenten einen
    Fallback). Wenn die Datei fehlt, wird die ``FileNotFoundError`` als
    klarer Vertrag-Bruch-Hinweis re-raised. Wenn ``GmiClient`` oder
    ``GmiHttpError`` fehlen, wird ein ``ImportError`` erhoben — kein stiller
    ``AttributeError``.
    """
    bundle_root = _resolve_bundle_root()
    try:
        mod = import_helper(
            "getmyinvoices",
            "gmi_client",
            bundle_root=bundle_root,
            subpath="scripts",
        )
    except FileNotFoundError as e:
        raise FileNotFoundError(
            "getmyinvoices Bundle-Skill nicht gefunden — depends_on-Vertrag "
            "verletzt. .skills/getmyinvoices/scripts/gmi_client.py existiert "
            f"nicht unter bundle_root. Originalmeldung: {e}"
        ) from e

    if not hasattr(mod, "GmiClient"):
        raise ImportError(
            "getmyinvoices/gmi_client.py exportiert keine GmiClient-Klasse. "
            "Der depends_on-Vertrag von gmi-accountant ist nicht erfüllt. "
            "Möglicherweise ist das Modul beschädigt oder die getmyinvoices-"
            "Version nicht kompatibel."
        )
    if not hasattr(mod, "GmiHttpError"):
        raise ImportError(
            "getmyinvoices/gmi_client.py exportiert keine GmiHttpError-Klasse. "
            "Der depends_on-Vertrag von gmi-accountant ist nicht erfüllt."
        )
    return mod


# Cross-Skill-Import beim Modul-Load. Wer das Modul importiert, hat den
# depends_on-Vertrag bereits geprüft.
_gmi = _load_gmi_client_module()
GmiClient = _gmi.GmiClient
GmiHttpError = _gmi.GmiHttpError


# Bundle-Standard ab v0.10.0: Retry-Backoff-Tuning.
# GMI-API dokumentiert 60 requests/min global + 5 downloads/document/day.
# Drei Versuche mit längeren Pausen (15, 60, 180), um das Pro-Minute-Budget
# zu schonen. Burst-Amplifikation ist die häufigere Fehlerquelle als
# einzelne 429-Schübe.
_RETRY_DELAYS = (15, 60, 180)

# Retryable: HTTP 429 (echtes Rate-Limit aus dem Reverse-Proxy) ODER
# HTTP 403 mit innerem errors[0].code == 429 (Rate-Limit aus dem
# Application Layer, dokumentiert im OpenAPI model403Error).
_RETRYABLE_STATUS = {429}
_RATE_LIMIT_INNER_CODE = 429

# Bundle-Standard ab v0.10.0: Pre-Call-Pause (Burst-Schutz).
# Sequenzielle Calls ohne Pause triggern die Rate-Limit-Drossel auch
# unter dem dokumentierten 60/min-Limit. 0.4s pro Call ist ein guter
# Kompromiss zwischen Latenz und Sicherheit.
_PRE_CALL_PAUSE = 0.4

T = TypeVar("T")


def _pre_call_pause() -> None:
    """Kurze Pause vor jedem HTTP-Call, um Burst-Drosselung zu vermeiden.

    Wird vor jedem tatsächlichen ``fn(*args, **kwargs)``-Aufruf in
    ``_call_with_retry`` ausgeführt, also auch vor Retry-Versuchen.

    In den Bundle-Eval-Tests wird die Pause nicht über eine Fixture gemockt
    — stattdessen umgeht der Mock-`GmiClient` aus
    `_lib/test_utils.make_gmi_client_mock` den `_call_with_retry`-Pfad
    komplett (`client.call(...)` ist eine direkte Modul-Methode ohne
    Bridge-Wrapper). Wer in-process auf den echten Retry-Pfad testet,
    sollte `_pre_call_pause` per `monkeypatch.setattr` selbst auf no-op
    setzen.
    """
    time.sleep(_PRE_CALL_PAUSE)


def _is_rate_limited(e: "GmiHttpError") -> bool:
    """True wenn die Exception ein Rate-Limit signalisiert."""
    status = getattr(e, "status", None)
    if status in _RETRYABLE_STATUS:
        return True
    if status == 403 and getattr(e, "code", None) == _RATE_LIMIT_INNER_CODE:
        return True
    return False


def _call_with_retry(fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
    """Ruft fn(*args, **kwargs) auf und retryt bei Rate-Limit transparent.

    Erkennt zwei Rate-Limit-Formen (siehe ``_is_rate_limited``).
    Nicht-Rate-Limit-Fehler (404, 500, 503, ...) werden sofort
    weitergereicht. Jeder Retry-Versuch wird auf stderr geloggt mit
    Funktionsname, Versuchs-Nummer, Status-Code, optionalem inneren Code
    und Sleep-Dauer. Kein stdout-Output, weil stdout für Workflow-JSON
    reserviert ist.
    """
    attempt = 0
    while True:
        _pre_call_pause()
        try:
            return fn(*args, **kwargs)
        except GmiHttpError as e:
            if not _is_rate_limited(e) or attempt >= len(_RETRY_DELAYS):
                raise
            sleep_for = _RETRY_DELAYS[attempt]
            fn_name = getattr(fn, "__name__", None) or getattr(fn, "__qualname__", "<lambda>")
            status = getattr(e, "status", None)
            inner_code = getattr(e, "code", None)
            code_part = f" code={inner_code}" if inner_code is not None else ""
            op_part = ""
            if args and isinstance(args[0], str):
                op_part = f" op={args[0]}"
            print(
                f"[gmi-bridge retry] fn={fn_name}{op_part} attempt={attempt + 1} "
                f"status={status}{code_part} sleeping={sleep_for}s",
                file=sys.stderr,
                flush=True,
            )
            time.sleep(sleep_for)
            attempt += 1


class BridgeError(Exception):
    """Fehler in der Bestandsskill-Bridge. Enthält niemals Key-Anteile."""


# Singleton-Cache: innerhalb einer Python-Session liefert build_client den
# gleichen GmiClient zurück, damit alle Workflows über eine requests.Session
# laufen und Connection-Pool teilen.
_CLIENT_CACHE: Dict[str, "GmiClient"] = {}


def _clear_client_cache() -> None:
    """Leert den Singleton-Cache. Für Test-Isolation und manuelle Resets."""
    _CLIENT_CACHE.clear()


def build_client() -> "GmiClient":
    """Baut einen GmiClient mit der Standard-Env-Var ``GETMYINVOICES_API_KEY``.

    Der Bundle-getmyinvoices-Client liest die Var intern direkt. Wenn sie
    fehlt, wirft er ``GmiConfigError``. Der Key wird niemals in stdout,
    Logs oder Exceptions geleakt.

    Singleton: wiederholte Aufrufe in derselben Python-Session liefern
    dieselbe Instanz und teilen den ``requests.Session``-Connection-Pool.
    """
    cached = _CLIENT_CACHE.get("default")
    if cached is not None:
        return cached
    client = GmiClient()
    _CLIENT_CACHE["default"] = client
    return client


def fetch_account_details(client: "GmiClient") -> Dict[str, Any]:
    """Liefert die Account-Details (accountUid, Feature-Flags etc.)."""
    return _call_with_retry(client.call, "getAccountDetails")


def fetch_company_list_smoke(client: "GmiClient") -> Any:
    """Smoke-Test für onboarding: ruft getCompanyList mit limit=1 auf."""
    return _call_with_retry(client.call, "getCompanyList", limit=1)


def fetch_document_list(
    client: "GmiClient", **filters: Any
) -> Iterator[Dict[str, Any]]:
    """Yieldet alle Dokumente, die zum Filter passen, paginiert.

    Die GMI-API liefert die Belege unter dem Schlüssel 'records'.
    """
    yield from _call_with_retry(
        client.paginate,
        "getDocumentList",
        items_key="records",
        page_size=500,
        **filters,
    )


def fetch_one_document_meta_via_list(
    client: "GmiClient",
    document_uid: int,
    **extra_filters: Any,
) -> Optional[Dict[str, Any]]:
    """Holt die Metadaten eines Dokuments über getDocumentList statt getOneDocument.

    Hintergrund: getOneDocument hat zusätzlich zum globalen 60/min-Limit
    ein Per-UID-Daily-Limit. Für reine Metadaten-Reads paginieren wir
    deshalb getDocumentList und filtern lokal nach documentUid.

    Returns:
        Ein Dict der Form ``{"meta_data": record}`` analog zu
        fetch_one_document, oder None wenn die UID nicht in der Liste ist.
    """
    filters = {"archivedFilter": 1}
    filters.update(extra_filters)
    pages = _call_with_retry(
        client.paginate,
        "getDocumentList",
        items_key="records",
        page_size=500,
        **filters,
    )
    for record in pages:
        if record.get("documentUid") == document_uid:
            return _strip_filecontent({"meta_data": record})
    return None


def _strip_filecontent(obj: Any) -> Any:
    """Rekursiv jeden fileContent-Schlüssel entfernen, auf jeder Tiefe."""
    if isinstance(obj, dict):
        return {
            k: _strip_filecontent(v)
            for k, v in obj.items()
            if k != "fileContent"
        }
    if isinstance(obj, list):
        return [_strip_filecontent(x) for x in obj]
    return obj


def download_pdf(
    client: "GmiClient", document_uid: int, target_path: str
) -> None:
    """Lädt das PDF eines Dokuments und schreibt es auf Disk.

    Bytes tauchen NICHT in stdout oder Return auf. Nutzt call_path mit
    binary=True, damit die Bytes direkt aus der HTTP-Response kommen.
    """
    raw_bytes = _call_with_retry(
        client.call_path,
        "GET", f"/documents/{document_uid}/file", binary=True,
    )
    with open(target_path, "wb") as f:
        f.write(raw_bytes)


# Whitelist erlaubter Body-Keys für updateDocument. Bundle-Standard ab
# v0.10.0: nur die im OpenAPI dokumentierten Schlüssel werden akzeptiert.
UPDATE_DOCUMENT_ALLOWED_KEYS: frozenset = frozenset({
    "companyId",
    "documentType",
    "documentNumber",
    "documentDate",
    "documentDueDate",
    "orderNumber",
    "netAmount",
    "grossAmount",
    "currency",
    "vat",
    "cashDiscountValue",
    "cashDiscountDate",
    "paymentMethod",
    "paymentStatus",
    "paidAt",
    "tags",
    "note",
    "isArchived",
    "lineItems",
    "taxRates",
})


def update_document(
    client: "GmiClient", document_uid: int, fields: Dict[str, Any]
) -> Dict[str, Any]:
    """Updated die Felder eines Dokuments (z.B. documentNumber, tags).

    Vor dem Call wird der Body gegen UPDATE_DOCUMENT_ALLOWED_KEYS geprüft.
    Unbekannte Keys werfen BridgeError, BEVOR ein API-Call rausgeht.
    """
    unknown = sorted(set(fields) - UPDATE_DOCUMENT_ALLOWED_KEYS)
    if unknown:
        raise BridgeError(
            "update_document: unbekannte Body-Keys "
            f"{unknown}. Erlaubt sind nur Keys aus UPDATE_DOCUMENT_ALLOWED_KEYS."
        )
    return _call_with_retry(
        client.call,
        "updateDocument", documentUid=document_uid, _body=fields,
    )


def fetch_tag_list(client: "GmiClient") -> List[str]:
    """Liefert alle Tag-Namen aus dem GMI-Account."""
    raw = _call_with_retry(client.call, "getTagList")
    if isinstance(raw, dict):
        data = raw.get("data", [])
        return data if isinstance(data, list) else []
    return []


# ---------------------------------------------------------------------------
# Tag-Helfer (Bundle-Standard ab v0.10.0)
# ---------------------------------------------------------------------------

STATUS_TAGS: frozenset = frozenset({
    "agent-done",
    "agent-review",
    "agent-skip",
    "agent-compliance-hold",
})


def set_status_tag(existing_tags: List[str], new_tag: str) -> List[str]:
    """Setzt einen Status-Tag exklusiv: alle anderen Status-Tags raus.

    Nicht-Status-Tags bleiben erhalten. Der neue Tag wird ans Ende
    gehängt. Input wird nicht mutiert.
    """
    cleaned = [t for t in (existing_tags or []) if t not in STATUS_TAGS]
    cleaned.append(new_tag)
    return cleaned
