# /// script
# requires-python = ">=3.11"
# dependencies = ["requests>=2.31,<3.0"]
# ///
"""Generischer Client für die GetMyInvoices API v3.

Sicherheitsregel: Der API-Key fliesst NIE in Logs, Exceptions oder Returns.
Validierung nur per Länge, niemals per Wert. Der Key existiert ausschließlich
in os.environ und im internen Auth-Header dieses Prozesses.
"""
from __future__ import annotations

import json
import os
import time
from pathlib import Path
from typing import Any, Dict, Iterator, Optional

try:
    import requests
except ImportError as exc:
    raise SystemExit(
        "GetMyInvoices-Skill benötigt die Bibliothek 'requests'. "
        "Installation im Bundle-venv: uv sync --extra gmi"
    ) from exc

REFERENCES_DIR = Path(__file__).parent.parent / "references"
SPEC_PATH = REFERENCES_DIR / "openapi.json"


class GmiError(Exception):
    """Basisklasse für alle Client-Fehler."""


class GmiConfigError(GmiError):
    """Konfiguration fehlt oder ist ungueltig."""


class GmiValidationError(GmiError):
    """Parameter-Validierung gegen die Spec ist fehlgeschlagen."""


class GmiHttpError(GmiError):
    """HTTP-Fehler von der API. Enthält KEINE Auth-Informationen."""

    def __init__(self, status: int, code: Optional[int] = None, detail: str = ""):
        self.status = status
        self.code = code
        self.detail = detail
        super().__init__(f"HTTP {status}: {detail}")


class GmiClient:
    def __init__(self, spec_path: Path = SPEC_PATH):
        api_key = os.environ.get("GETMYINVOICES_API_KEY", "")
        if not api_key:
            raise GmiConfigError(
                "GETMYINVOICES_API_KEY nicht gesetzt. Bitte als Windows-User-Env-Var anlegen."
            )
        # Key nur intern halten, niemals in Repr/Logs ausgeben
        self._api_key = api_key

        with open(spec_path, encoding="utf-8") as f:
            self._spec = json.load(f)

        self.base_url = self._spec["servers"][0]["url"]
        self._operations = self._index_operations()
        self._session = requests.Session()
        # Content-Type: application/json ist Pflicht auf ALLEN Methoden, auch
        # DELETE ohne Body. Der Server antwortet sonst mit HTTP 415
        # ("Request content-type is not valid"). Accept setzen wir defensiv mit.
        self._session.headers.update(
            {
                "X-API-KEY": self._api_key,
                "Content-Type": "application/json",
                "Accept": "application/json",
            }
        )

    def __repr__(self) -> str:
        # KEIN Key, KEINE Auth-Infos
        return f"GmiClient(base_url={self.base_url!r}, operations={len(self._operations)})"

    def _resolve_ref(self, ref: str) -> Dict[str, Any]:
        """Loest #/components/parameters/Foo zu einem Parameter-Objekt auf."""
        assert ref.startswith("#/")
        node: Any = self._spec
        for part in ref.lstrip("#/").split("/"):
            node = node[part]
        return node

    def _resolve_parameters(self, operation_id: str) -> list:
        """Liefert alle Parameter einer Operation, inkl. aufgeloester Refs."""
        if operation_id not in self._operations:
            raise GmiValidationError(f"Unbekannte operationId: {operation_id}")
        op = self._operations[operation_id]["operation"]
        resolved = []
        for p in op.get("parameters", []):
            if "$ref" in p:
                resolved.append(self._resolve_ref(p["$ref"]))
            else:
                resolved.append(p)
        return resolved

    def _validate_and_split(
        self, operation_id: str, params: Dict[str, Any]
    ) -> Dict[str, Dict[str, Any]]:
        """Validiert Parameter gegen die Spec und sortiert sie nach 'in'-Position.

        Returns: {"path": {...}, "query": {...}, "header": {...}}

        Hinweis: Header-Parameter (z.B. User-Agent) werden NICHT auf 'required'
        geprüft, da sie vom Client/User zur Laufzeit injiziert werden.
        Pflichtpruefung gilt nur für path und query.
        """
        spec_params = self._resolve_parameters(operation_id)
        spec_by_name = {p["name"]: p for p in spec_params}

        # Unbekannte Parameter ablehnen. Der Sonderparameter '_body' wird vom
        # Client selbst verwendet, um einen requestBody zu transportieren, und
        # ist nie in der Spec enthalten — also überspringen.
        for name in params:
            if name == "_body":
                continue
            if name not in spec_by_name:
                raise GmiValidationError(
                    f"Unbekannter Parameter '{name}' für {operation_id}"
                )

        # Pflicht-Parameter prüfen — nur path und query
        for p in spec_params:
            if p.get("in") == "header":
                continue
            if p.get("required") and p["name"] not in params:
                raise GmiValidationError(
                    f"Pflicht-Parameter '{p['name']}' fehlt für {operation_id}"
                )

        # Nach 'in' sortieren
        result: Dict[str, Dict[str, Any]] = {"path": {}, "query": {}, "header": {}}
        for name, value in params.items():
            if name == "_body":
                continue  # Body wird separat verarbeitet
            location = spec_by_name[name].get("in", "query")
            if location not in result:
                result[location] = {}
            result[location][name] = value
        return result

    def _index_operations(self) -> Dict[str, Dict[str, Any]]:
        """Indexiert alle operationIds zu Methode + Pfad + Operation-Objekt."""
        index: Dict[str, Dict[str, Any]] = {}
        for path, path_item in self._spec.get("paths", {}).items():
            for method, op in path_item.items():
                if method not in ("get", "post", "put", "delete", "patch"):
                    continue
                op_id = op.get("operationId")
                if not op_id:
                    continue
                index[op_id] = {
                    "method": method.upper(),
                    "path": path,
                    "operation": op,
                }
        return index

    def call(self, operation_id: str, **params: Any) -> Any:
        """Ruft eine Operation auf. Liefert dict (JSON), bytes (binär) oder None.

        Sicherheit: Der API-Key wird intern als Header gesetzt und niemals geloggt.
        Retry: Bei 503 oder Netzwerkfehlern werden bis zu 3 Versuche gemacht.

        Body: Für POST/PUT/PATCH-Operationen kann der requestBody via
              Sonderparameter '_body=<dict>' übergeben werden. Dieser wird
              als application/json mitgeschickt.
        """
        if operation_id not in self._operations:
            raise GmiValidationError(f"Unbekannte operationId: {operation_id}")

        op_info = self._operations[operation_id]
        method = op_info["method"]
        path_template = op_info["path"]

        # Body separat herausziehen, damit _validate_and_split ihn nicht als Query sieht
        body = params.pop("_body", None)

        parts = self._validate_and_split(operation_id, params)

        # Pfad-Substitution
        url_path = path_template
        for name, value in parts["path"].items():
            url_path = url_path.replace("{" + name + "}", str(value))
        url = self.base_url + url_path

        headers = dict(parts["header"])

        max_attempts = 3
        for attempt in range(1, max_attempts + 1):
            try:
                response = self._session.request(
                    method=method,
                    url=url,
                    params=parts["query"] or None,
                    headers=headers or None,
                    json=body if body is not None else None,
                    timeout=30,
                )
            except requests.RequestException as e:
                if attempt < max_attempts:
                    time.sleep(2 ** (attempt - 1))
                    continue
                # KEINE Auth-Infos in der Exception
                raise GmiHttpError(status=0, detail="Netzwerkfehler") from e

            if response.status_code == 503 and attempt < max_attempts:
                time.sleep(2 ** (attempt - 1))
                continue

            return self._handle_response(response)

        # Fallback (sollte nie erreicht werden)
        raise GmiHttpError(status=0, detail="Retry erschoepft")

    def _handle_response(self, response: Any) -> Any:
        status = response.status_code
        if 200 <= status < 300:
            content_type = response.headers.get("Content-Type", "")
            if content_type.startswith("application/json"):
                return response.json()
            return response.content
        # Fehler — KEINE Auth-Infos in der Exception.
        # Echte GMI-Fehlerform: {"errors":[{"code":145,"detail":"Bank account not found."}]}.
        # Zusätzlich tolerant gegen Top-Level {"code","detail"} (legacy/fallback).
        code = None
        detail = ""
        try:
            body = response.json()
        except Exception:
            body = None
        if isinstance(body, dict):
            errors = body.get("errors")
            if isinstance(errors, list) and errors and isinstance(errors[0], dict):
                code = errors[0].get("code")
                detail = errors[0].get("detail") or ""
            else:
                code = body.get("code")
                detail = body.get("detail") or ""
        raise GmiHttpError(status=status, code=code, detail=detail)

    def paginate(
        self,
        operation_id: str,
        items_key: str,
        page_size: int = 100,
        **params: Any,
    ) -> Iterator[Dict[str, Any]]:
        """Liefert alle Items einer paginierten Operation als Generator.

        items_key: Name des Felds im Response, das die Items enthält
                   (z.B. 'documents' für getDocumentList).
        """
        page = 1
        while True:
            response = self.call(
                operation_id,
                pageNumber=page,
                perPage=page_size,
                **params,
            )
            items = response.get(items_key, []) if isinstance(response, dict) else []
            if not items:
                return
            for item in items:
                yield item
            page += 1

    def paginate_offset(
        self,
        operation_id: str,
        items_key: str,
        limit: int = 100,
        max_items: Optional[int] = None,
        **params: Any,
    ) -> Iterator[Dict[str, Any]]:
        """Liefert alle Items einer Operation, die offset/limit-Pagination nutzt.

        Im Gegensatz zu paginate() (pageNumber/perPage) verwenden Bank-Endpoints
        wie listTransaction einen offset-basierten Stil.

        items_key: Name des Felds im Response mit den Items (z.B. 'records').
        max_items: optionales Limit, bricht die Schleife früh ab.
        """
        offset = 0
        yielded = 0
        while True:
            response = self.call(
                operation_id,
                offset=offset,
                limit=limit,
                **params,
            )
            items = response.get(items_key, []) if isinstance(response, dict) else []
            if not items:
                return
            for item in items:
                yield item
                yielded += 1
                if max_items is not None and yielded >= max_items:
                    return
            if len(items) < limit:
                return
            offset += limit

    def call_path(
        self,
        method: str,
        path: str,
        params: Optional[Dict[str, Any]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        binary: bool = False,
    ) -> Any:
        """Notausgang: direkter HTTP-Call ohne Spec-Validierung.

        Nur verwenden, wenn call() mit operationId nicht funktioniert.

        binary=True erzwingt bei 2xx-Antworten die Rückgabe als rohe Bytes
        (response.content), unabhängig vom Content-Type-Header. Für
        Binär-Downloads wie PDFs. Fehler-Responses werden weiterhin als
        JSON geparst (Fehlerstruktur der API bleibt maschinenlesbar).
        """
        url = self.base_url + path
        max_attempts = 3
        for attempt in range(1, max_attempts + 1):
            try:
                response = self._session.request(
                    method=method.upper(),
                    url=url,
                    params=params or None,
                    json=json_body if json_body else None,
                    timeout=30,
                )
            except requests.RequestException as e:
                if attempt < max_attempts:
                    time.sleep(2 ** (attempt - 1))
                    continue
                raise GmiHttpError(status=0, detail="Netzwerkfehler") from e

            if response.status_code == 503 and attempt < max_attempts:
                time.sleep(2 ** (attempt - 1))
                continue

            if binary and 200 <= response.status_code < 300:
                return response.content
            return self._handle_response(response)

        raise GmiHttpError(status=0, detail="Retry erschoepft")
