"""cryptpe.py — official Python SDK for the crypt.pe payment gateway.

Install
-------
    # single file — drop into your project, or:
    curl https://crypt.pe/api/sdk/python -o cryptpe.py

    # ...or via pip once published:
    pip install cryptpe

Quick start
-----------

    from cryptpe import Cryptpe

    cryptpe = Cryptpe(os.environ["CRYPTPE_SECRET_KEY"])

    # 1. Create an order on your /checkout view
    order = cryptpe.payments.create(
        amount_usd     = 49.00,
        accepted_coins = ["eth", "usdc-erc20", "matic"],
        return_url     = "https://your-shop.com/thanks",
        webhook_url    = "https://your-shop.com/webhooks/cryptpe",
        customer_email = "buyer@example.com",
        metadata       = {"shopify_order_id": "1234"},
    )
    return redirect(order["checkout_url"])

    # 2. Verify the inbound webhook signature in your handler
    @app.route("/webhooks/cryptpe", methods=["POST"])
    def cryptpe_webhook():
        try:
            event = cryptpe.webhooks.verify(
                raw_body  = request.get_data(),
                signature = request.headers["X-Cryptpe-Signature"],
                secret    = os.environ["CRYPTPE_WEBHOOK_SECRET"],
            )
        except CryptpeError as e:
            return ("bad signature: %s" % e), 400
        if event["type"] == "payment.confirmed":
            mark_order_paid(event["data"]["order_id"])
        return {"received": True}

    # 3. Issue a refund record once you've sent the on-chain refund yourself
    cryptpe.payments.refund(order["order_id"],
                            tx_hash="0xabc...",
                            reason="customer request")

    # 4. Cancel a pending checkout the customer abandoned
    cryptpe.payments.cancel(order["order_id"])

Dependencies
------------
Only the Python stdlib (`urllib`, `hmac`, `hashlib`, `json`). No `requests`
needed — drop the file in and go.

Compatibility
-------------
Python 3.8+ (uses `typing.Optional`, no walrus operator, no `match`).
"""
from __future__ import annotations

import hmac
import hashlib
import json
import time
import urllib.request
import urllib.error
from typing import Any, Dict, Optional

DEFAULT_BASE = "https://crypt.pe"
DEFAULT_TIMEOUT = 10
SIGNATURE_TOLERANCE_S = 300  # 5-minute replay window, matches Stripe


# =============================================================================
# Exceptions
# =============================================================================

class CryptpeError(Exception):
    """All SDK-raised errors. `status_code` and `body` are populated for
    HTTP failures so callers can branch on validation errors vs network ones.
    """
    def __init__(self, message: str, *, status_code: Optional[int] = None,
                 body: Optional[Any] = None):
        super().__init__(message)
        self.status_code = status_code
        self.body = body


# =============================================================================
# Client
# =============================================================================

class Cryptpe:
    """Top-level client. Keeps your secret key, exposes namespaces.

    Args:
        api_key:  sk_live_* secret (NEVER ship to the browser)
        base_url: override for staging / self-hosted tests
        timeout:  per-request timeout in seconds
    """

    def __init__(self, api_key: str, *,
                 base_url: str = DEFAULT_BASE,
                 timeout: int = DEFAULT_TIMEOUT):
        if not api_key or not api_key.startswith("sk_live_"):
            raise CryptpeError("Cryptpe: api_key must start with sk_live_")
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.payments = _Payments(self)
        self.webhooks = _Webhooks()

    # ------- internal HTTP --------------------------------------------------

    def _request(self, method: str, path: str,
                 body: Optional[Dict[str, Any]] = None,
                 *, idempotency_key: Optional[str] = None) -> Any:
        url = f"{self.base_url}/api{path}"
        data = json.dumps(body).encode("utf-8") if body is not None else None
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type":  "application/json",
            "Accept":        "application/json",
            "User-Agent":    "cryptpe-python/1.0",
        }
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key

        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
                raw = r.read()
        except urllib.error.HTTPError as e:
            raw = e.read() if hasattr(e, "read") else b""
            try:
                parsed = json.loads(raw.decode("utf-8")) if raw else None
            except Exception:
                parsed = raw.decode("utf-8", errors="replace")
            detail = parsed.get("detail") if isinstance(parsed, dict) else parsed
            raise CryptpeError(
                f"HTTP {e.code}: {detail}",
                status_code=e.code,
                body=parsed,
            ) from None
        except urllib.error.URLError as e:
            raise CryptpeError(f"Network error: {e.reason}") from None

        if not raw:
            return None
        try:
            return json.loads(raw.decode("utf-8"))
        except Exception:
            return raw.decode("utf-8", errors="replace")


# =============================================================================
# Payments namespace
# =============================================================================

class _Payments:
    def __init__(self, client: "Cryptpe"):
        self._c = client

    def create(self,
               amount_usd: float,
               accepted_coins: list,
               *,
               return_url: Optional[str] = None,
               webhook_url: Optional[str] = None,
               customer_email: Optional[str] = None,
               metadata: Optional[Dict[str, Any]] = None,
               expires_in_minutes: Optional[int] = None,
               idempotency_key: Optional[str] = None) -> Dict[str, Any]:
        """Create a new payment order. Returns the order JSON including
        `checkout_url` to redirect your customer to."""
        body: Dict[str, Any] = {
            "amount_usd": amount_usd,
            "accepted_coins": accepted_coins,
        }
        if return_url:
            body["return_url"] = return_url
        if webhook_url:
            body["webhook_url"] = webhook_url
        if customer_email:
            body["customer_email"] = customer_email
        if metadata:
            body["metadata"] = metadata
        if expires_in_minutes:
            body["expires_in_minutes"] = expires_in_minutes
        return self._c._request("POST", "/v1/payments", body,
                                idempotency_key=idempotency_key)

    def retrieve(self, order_id: str) -> Dict[str, Any]:
        """Return the latest state of an order."""
        return self._c._request("GET", f"/v1/payments/{order_id}")

    def refund(self, order_id: str, *,
               tx_hash: str,
               amount_crypto: Optional[float] = None,
               reason: Optional[str] = None,
               notes: Optional[str] = None) -> Dict[str, Any]:
        """Record an on-chain refund the merchant has already sent.

        crypt.pe is non-custodial — we never move funds. This endpoint
        ONLY persists the refund hash and flips the order status to
        `refunded` (or `partially_refunded`) so your accounting and
        downstream webhooks stay consistent. Send the actual refund tx
        from your own wallet first, then call this with the resulting hash.
        """
        body: Dict[str, Any] = {"tx_hash": tx_hash}
        if amount_crypto is not None:
            body["amount_crypto"] = amount_crypto
        if reason:
            body["reason"] = reason
        if notes:
            body["notes"] = notes
        return self._c._request("POST", f"/v1/payments/{order_id}/refund", body)

    def cancel(self, order_id: str) -> Dict[str, Any]:
        """Cancel a pending order the customer abandoned. Once funds are
        in flight (`paid_unconfirmed` and beyond) cancel is rejected —
        use `refund(...)` to record an on-chain return instead."""
        return self._c._request("POST", f"/v1/payments/{order_id}/cancel", {})


# =============================================================================
# Webhooks namespace
# =============================================================================

class _Webhooks:
    """Stateless helper for verifying inbound webhook signatures."""

    def verify(self, *,
               raw_body,
               signature: str,
               secret: str,
               tolerance: int = SIGNATURE_TOLERANCE_S) -> Dict[str, Any]:
        """Verify an inbound `X-Cryptpe-Signature` header. Raises
        CryptpeError on tampering or replay. Returns the parsed event JSON
        on success.

        Pass the request body EXACTLY as it arrived (bytes preferred). Do
        NOT re-serialise — even whitespace changes invalidate the HMAC.

        Args:
            raw_body:   bytes (preferred) or str of the request body
            signature:  value of the `X-Cryptpe-Signature` header
            secret:     your `whsec_*` (returned at API key creation)
            tolerance:  max age in seconds before we reject (default 300)
        """
        if not signature:
            raise CryptpeError("missing X-Cryptpe-Signature header")
        if not secret:
            raise CryptpeError("missing webhook secret")

        # Header format: `t=<unix>,v1=<hex>` — same as Stripe
        parts = {}
        for chunk in str(signature).split(","):
            if "=" not in chunk:
                continue
            k, _, v = chunk.strip().partition("=")
            parts[k] = v
        try:
            ts = int(parts.get("t", "0"))
        except ValueError:
            raise CryptpeError("malformed signature header (bad timestamp)")
        v1 = parts.get("v1")
        if not ts or not v1:
            raise CryptpeError("malformed signature header")

        age_s = int(time.time()) - ts
        if age_s > tolerance:
            raise CryptpeError(f"signature too old ({age_s}s > {tolerance}s)")

        body_bytes = raw_body if isinstance(raw_body, (bytes, bytearray)) \
                              else str(raw_body).encode("utf-8")
        signed_payload = f"{ts}.".encode("utf-8") + body_bytes
        expected = hmac.new(secret.encode("utf-8"), signed_payload,
                            hashlib.sha256).hexdigest()
        # Constant-time compare to avoid timing leaks.
        if not hmac.compare_digest(expected, v1):
            raise CryptpeError("signature mismatch")

        try:
            return json.loads(body_bytes.decode("utf-8"))
        except Exception as e:
            raise CryptpeError(f"event body is not valid JSON: {e}") from None


__all__ = ["Cryptpe", "CryptpeError"]
