"""
Developer Documentation Portal.
Registered on the root FastAPI app (not api_router) so URL is /developer-portal.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse

portal_router = APIRouter(tags=["Portal"])

DEVELOPER_PATHS = {
    # ── Transactions ──────────────────────────────────────────────────────────
    "/api/v1/transactions",
    "/api/v1/transactions/summary",
    "/api/v1/transactions/{transaction_id}",
    "/api/v1/transactions/{transaction_id}/refund",
    "/api/v1/transactions/{transaction_id}/receipt",   # Download branded receipt PDF
    # ── Payment Requests ──────────────────────────────────────────────────────
    "/api/v1/payment-requests",
    # ── Customers ─────────────────────────────────────────────────────────────
    "/api/v1/customers",
    "/api/v1/customers/{customer_id}",
    "/api/v1/customers/{customer_id}/contacts",
    "/api/v1/customers/{customer_id}/contacts/{contact_id}",
    # ── Products ──────────────────────────────────────────────────────────────
    "/api/v1/products",
    # ── Invoices ──────────────────────────────────────────────────────────────
    "/api/v1/invoices/{invoice_literal}",
    "/api/v1/invoices/{invoice_literal}/pdf",          # Download invoice PDF
    # ── Developer management (requires merchant JWT) ──────────────────────────
    "/api/v1/developer/api-keys",
    "/api/v1/developer/api-keys/{id}",
    "/api/v1/developer/webhooks",
    "/api/v1/developer/webhooks/{id}",
    "/api/v1/developer/webhooks/{id}/rotate-secret",
    "/api/v1/developer/webhooks/{id}/deliveries",
    "/api/v1/developer/logs",
}

CORE_CONCEPTS_MARKDOWN = """
# HubWallet Developer API

Integrate HubWallet's payment processing into your backend systems using our REST API and webhook events.

---

## 1. Authentication

All API requests require an API key. Generate one from your HubWallet account under **Settings → Developer → API Keys**.

Two equivalent authentication methods are supported:

```http
# Option A: X-API-Key header
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxx

# Option B: Bearer token
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxx
```

### API Key Scopes

Each key is issued with one or more scopes that restrict what it can do. Requests using a key without the required scope receive **HTTP 403**.

| Scope | Grants Access To |
|-------|-----------------|
| `payments:read` | GET /transactions, GET /transactions/summary, GET /customers, GET /products |
| `payments:write` | POST /payment-requests |
| `payments:refund` | POST /transactions/{id}/refund |

> **Best practice:** Issue keys with the minimum scopes your integration needs. Use separate keys for read-only reporting vs. payment initiation.

---

## 2. Environments

Keys are prefixed to indicate the environment they target:

| Prefix | Environment | Behaviour |
|--------|-------------|-----------|
| `sk_live_` | Live | Processes real payments against your live merchant account |
| `sk_test_` | Test | Requests are logged with `environment=test`; currently execute against the same processor (full sandbox deferred to v1.1) |

---

## 3. Response Envelope

Every successful response is wrapped in a standard envelope:

```json
{
  "data": { ... },
  "success": true,
  "message": null
}
```

Your application should read the `data` field for the actual payload.

---

## 4. Payment Request Lifecycle

A **Payment Request** is the central object. Understanding its lifecycle is key to integrating correctly.

```
POST /payment-requests
        │
        ▼
   Status: PENDING
        │
        ▼  (payment processed by provider)
   Status: PAID  ──► (optional) POST /transactions/{id}/refund
        │                               │
        ▼                               ▼
   Invoice created              Status: REFUNDED
```

### Payment Frequency

| Value | Description |
|-------|-------------|
| `one_time` | Single charge — most common |
| `split` | Amount split across multiple payers |
| `recurring` | Subscription-style repeating charge |

### Authorization Type

Controls whether the payer must provide explicit consent before payment is processed.

| Value | Description |
|-------|-------------|
| `pre_auth` | Payer must check a checkbox (CHECKBOX) at payment time — implicit consent |
| `request_auth` | An authorization request is sent to the payer (via SMS or signature) before payment proceeds |

### Payment Method

| Value | Description |
|-------|-------------|
| `card` | Credit / debit card via hosted iframe |
| `ach` | ACH bank transfer |
| `cash` | Cash payment recorded manually |
| `cheque` | Cheque payment recorded manually |

---

## 5. Customers

Customers are identified by their `account_literal` (e.g. `ACCDRE954419A09`) — a unique alphanumeric literal assigned by HubWallet. Pass this value in the `customer_id` field when creating a payment request.

Use `GET /customers` to search and retrieve customer records before initiating payments.

---

## 6. Webhooks

Subscribe to real-time payment lifecycle events by creating a webhook endpoint under **Settings → Developer → Webhooks**.

### Delivery Behaviour

- Your endpoint must respond within **5 seconds** with an HTTP 2xx status.
- On failure, deliveries are retried with exponential backoff — up to **5 attempts** (delays: 1 min → 5 min → 30 min → 2 hr → 2 hr).
- After **10 consecutive failures** the endpoint is automatically disabled.
- Return **HTTP 200 immediately**, then process the event asynchronously (queue it) to avoid timeouts.

### HTTP Headers on Delivery

```
POST https://yourapp.com/webhooks/hubwallet
Content-Type: application/json
X-HubWallet-Signature: t=1712345678,v1=abc123...
X-HubWallet-Event: transaction.completed
```

---

### Event: `transaction.completed`

Fired when a payment is successfully processed by the payment provider.

```json
{
  "event_type": "transaction.completed",
  "event_id": "evt_a1b2c3d4e5f6",
  "timestamp": 1712345678,
  "merchant_id": 12,
  "data": {
    "transaction_id": 88,
    "txn_id": "txn_4kQmXr9LpW",
    "txn_amount": 150.00,
    "payment_request_id": 101,
    "merchant_id": 12,
    "customer_id": 5,
    "is_scheduled": false,
    "is_retry": false
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `transaction_id` | int | HubWallet internal transaction ID |
| `txn_id` | string | Provider-assigned transaction reference |
| `txn_amount` | float | Charged amount |
| `payment_request_id` | int | Linked payment request ID |
| `customer_id` | int | Customer who made the payment |
| `is_scheduled` | bool | `true` if this was a scheduled split/recurring instalment |
| `is_retry` | bool | `true` if this completed a previously failed retry flow |

---

### Event: `transaction.failed`

Fired when a payment attempt is declined or errors out.

```json
{
  "event_type": "transaction.failed",
  "event_id": "evt_b2c3d4e5f6a1",
  "timestamp": 1712345900,
  "merchant_id": 12,
  "data": {
    "transaction_id": 89,
    "txn_amount": 150.00,
    "payment_request_id": 101,
    "merchant_id": 12,
    "customer_id": 5,
    "retry_token": null,
    "retry_link": null,
    "is_merchant_triggered": false
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `transaction_id` | int | Failed transaction ID |
| `txn_amount` | float | Amount that was attempted |
| `retry_token` | string \| null | Populated when merchant triggers a retry flow |
| `retry_link` | string \| null | Full URL sent to the customer to retry payment |
| `is_merchant_triggered` | bool | `true` if the retry was initiated by the merchant |

---

### Event: `transaction.refunded`

Fired when a full or partial refund is processed.

```json
{
  "event_type": "transaction.refunded",
  "event_id": "evt_c3d4e5f6a1b2",
  "timestamp": 1712346100,
  "merchant_id": 12,
  "data": {
    "transaction_id": 88,
    "payment_request_id": 101,
    "merchant_id": 12,
    "refund_amount": 50.00,
    "currency": "USD"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `transaction_id` | int | Original transaction that was refunded |
| `refund_amount` | float | Amount refunded (may be partial) |
| `currency` | string | ISO 4217 currency code |

---

### Event: `payment_request.created`

Fired when a new payment request is created (via API or merchant portal).

```json
{
  "event_type": "payment_request.created",
  "event_id": "evt_d4e5f6a1b2c3",
  "timestamp": 1712340000,
  "merchant_id": 12,
  "data": {
    "payment_request_id": 101,
    "merchant_id": 12,
    "amount": 150.00,
    "currency": "USD",
    "payment_method_token": null,
    "payment_method_type": null,
    "customer_id": 5,
    "correlation_id": null
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `payment_request_id` | int | Newly created payment request ID |
| `amount` | float | Total charge amount |
| `currency` | string | ISO 4217 currency code |
| `payment_method_token` | string \| null | Token if a saved payment method was pre-attached |
| `customer_id` | int \| null | Linked customer, if any |
| `correlation_id` | string \| null | Tracing ID propagated from the originating request |

---

### Event: `payment_request.updated`

Fired when a payment request's status or properties change.

```json
{
  "event_type": "payment_request.updated",
  "event_id": "evt_e5f6a1b2c3d4",
  "timestamp": 1712345700,
  "merchant_id": 12,
  "data": {
    "payment_request_id": 101,
    "merchant_id": 12,
    "changed_fields": {
      "status": "completed"
    }
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `changed_fields` | object | Map of field names to their new values |

---

### Event: `payment_request.cancelled`

Fired when a payment request is cancelled.

```json
{
  "event_type": "payment_request.cancelled",
  "event_id": "evt_f6a1b2c3d4e5",
  "timestamp": 1712346500,
  "merchant_id": 12,
  "data": {
    "payment_request_id": 101,
    "merchant_id": 12,
    "reason": "Customer requested cancellation"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `reason` | string \| null | Optional cancellation reason |

---

### Signature Verification

Every webhook POST includes an `X-HubWallet-Signature` header. **Always verify it** before processing the event to prevent spoofed requests.

```
X-HubWallet-Signature: t=1712345678,v1=abc123...
```

Verification algorithm — HMAC-SHA256 over `{timestamp}.{raw_body}`:

```python
import hashlib, hmac, time

def verify_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    timestamp = int(parts["t"])
    received_sig = parts["v1"]

    # Reject requests older than 5 minutes (replay attack protection)
    if abs(time.time() - timestamp) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body.decode()}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received_sig)
```

```javascript
// Node.js equivalent
const crypto = require("crypto");

function verifyWebhook(rawBody, sigHeader, secret) {
  const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=")));
  const timestamp = parseInt(parts.t);
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

### Auto-Disable Behavior

If your endpoint returns non-2xx responses **10 consecutive times**, it is automatically disabled. Re-enable it from **Settings → Developer → Webhooks** after fixing the issue.

> **Tip:** Return HTTP 200 immediately, then process the event asynchronously (queue it). This prevents timeouts from disabling your endpoint.

---

## 7. Errors

All errors follow a standard envelope:

```json
{
  "status_code": 403,
  "message": "Insufficient scope: payments:write required",
  "error": "FORBIDDEN",
  "timestamp": "2026-04-08T12:00:00Z",
  "request_id": "req_abc123"
}
```

### Common Error Codes

| HTTP Status | Error | Meaning |
|-------------|-------|---------|
| 400 | `BAD_REQUEST` | Invalid request body or missing required field |
| 401 | `UNAUTHORIZED` | Missing, invalid, or expired API key |
| 403 | `FORBIDDEN` | API key lacks the required scope |
| 404 | `NOT_FOUND` | Resource does not exist or belongs to another merchant |
| 422 | `VALIDATION_ERROR` | Request body failed schema validation |
| 429 | `RATE_LIMITED` | Too many requests — back off and retry |
| 500 | `INTERNAL_ERROR` | Unexpected server error — contact support with `request_id` |

---

## 8. Pagination

All list endpoints (`GET /transactions`, `GET /customers`, etc.) are paginated.

**Query parameters:**

| Parameter | Default | Max | Description |
|-----------|---------|-----|-------------|
| `page` | `1` | — | Page number (1-based) |
| `page_size` | `20` | `100` | Results per page |

**Response shape:**

```json
{
  "data": {
    "items": [ { ... }, { ... } ],
    "total": 150,
    "page": 1,
    "page_size": 20
  }
}
```

To iterate all results:

```python
page = 1
while True:
    resp = requests.get(
        "https://api.hubwallet.com/api/v1/transactions",
        headers={"X-API-Key": API_KEY},
        params={"page": page, "page_size": 100}
    )
    data = resp.json()["data"]
    process(data["items"])
    if page * data["page_size"] >= data["total"]:
        break
    page += 1
```

---

## 9. Rate Limits

Requests are rate-limited per API key. On limit breach the API returns **HTTP 429**:

```json
{ "message": "Rate limit exceeded. Retry after 60 seconds." }
```

Implement exponential backoff when you receive a 429.

---

## 10. Idempotency

For `POST` requests (especially `POST /payment-requests`), include an idempotency key to safely retry failed requests without creating duplicate charges:

```http
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```

Use a UUID v4 generated per logical operation. Retrying with the same key within 24 hours returns the original response without re-processing.

---

## 11. Virtual Terminal Flow

The **Virtual Terminal (VT)** lets you collect payments programmatically — no hosted checkout UI required on your end. You create a payment request, receive a hosted payment URL, and listen for the outcome via webhooks.

Two authorization modes are available depending on the consent model your business requires.

---

### Mode A — Pre-Authorization (Checkbox)

The payer provides implicit consent by ticking a checkbox on the HubWallet hosted payment page before submitting payment.

**Best for:** Standard one-time charges initiated on behalf of a customer; minimal friction.

```
Step 1 — Look up or create the customer
──────────────────────────────────────
POST /customers
{
  "first_name": "Jane",
  "last_name": "Smith",
  "email": "jane.smith@example.com",
  "phone": "+15550100"
}
Response → { "id": 5, "customer_id": "ACCDRE954419A09", ... }

Step 2 — Create the payment request
────────────────────────────────────
POST /payment-requests
{
  "amount": 15000,
  "currency": "usd",
  "customer_id": "ACCDRE954419A09",
  "payment_frequency": "one_time",
  "authorization_type": "pre_auth",
  "due_date": "2026-05-30T00:00:00Z",
  "description": "Web Design Services — April 2026",
  "require_billing_address": true,
  "require_cvv": true,
  "shipping_fee": 0
}
Response → { "id": 101, "payment_url": "https://pay.hubwallet.com/hpp/PR-101", ... }

Step 3 — Send the payment URL to your customer
───────────────────────────────────────────────
Email or SMS  →  "https://pay.hubwallet.com/hpp/PR-101"

Step 4 — Receive the outcome via webhook
─────────────────────────────────────────
POST https://yourapp.com/webhooks/hubwallet
{ "event_type": "transaction.completed", "data": { "transaction_id": 88, ... } }

Step 5 — Fetch the final transaction record
────────────────────────────────────────────
GET /transactions/88
Response → { txn_status: "approved", txn_amount: 150.00, ... }

Step 6 — Download the branded receipt PDF
──────────────────────────────────────────
GET /transactions/{txn_literal}/receipt
Response → application/pdf
```

---

### Mode B — Request Authorization (SMS / Signature)

An explicit authorization request is sent to the payer — via SMS or digital signature — **before** payment is processed. The payment only proceeds once the customer provides documented consent.

**Best for:** High-value charges, regulated industries (healthcare, legal, finance), or any scenario that requires a verifiable consent trail.

```
Step 1 — Look up or create the customer
──────────────────────────────────────
GET /customers?search=jane.smith@example.com
Response → [ { "id": 5, "customer_id": "ACCDRE954419A09" } ]

Step 2 — Create the authorization-gated payment request
────────────────────────────────────────────────────────
POST /payment-requests
{
  "amount": 250000,
  "currency": "usd",
  "customer_id": "ACCDRE954419A09",
  "payment_frequency": "one_time",
  "authorization_type": "request_auth",
  "due_date": "2026-05-30T00:00:00Z",
  "description": "Legal Services Retainer — Q2 2026",
  "enable_email": true,
  "enable_sms": true
}
Response → { "id": 102, "payment_url": "https://pay.hubwallet.com/hpp/PR-102", ... }

NOTE: enable_email is required when authorization_type=request_auth

Step 3 — Authorization request sent to customer (out-of-band)
──────────────────────────────────────────────────────────────
SMS to customer: "Please authorize payment of $2,500 — reply YES or click link"

Step 4 — Customer approves; payment is processed
──────────────────────────────────────────────────
(No action required from your server at this point)

Step 5 — Webhook: transaction.completed
────────────────────────────────────────
POST https://yourapp.com/webhooks/hubwallet
{ "event_type": "transaction.completed", "data": { "transaction_id": 89 } }

Step 6 — Download the auto-generated invoice PDF
──────────────────────────────────────────────────
GET /transactions/89  →  find invoice_literal in response.invoices[0]
GET /invoices/{invoice_literal}/pdf
Response → application/pdf
```

---

### VT — Key Request Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount` | int | ✓ | Charge amount **in cents** (e.g. `15000` = $150.00) |
| `currency` | string | ✓ | Lowercase ISO 4217 code: `usd`, `aud`, `eur`, etc. |
| `customer_id` | string | ✓ | Customer literal ID (e.g. `ACCDRE954419A09`) from `GET /customers` |
| `payment_frequency` | string | ✓ | `one_time`, `recurring`, or `split` |
| `authorization_type` | string | ✓ | `pre_auth` or `request_auth` |
| `due_date` | ISO 8601 | ✓ one_time | Required when `payment_frequency=one_time` |
| `description` | string | | Shown on receipt and payment page |
| `reference` | string | | Your internal reference (order ID, invoice #) |
| `require_billing_address` | bool | | Default `false` — force address entry |
| `require_cvv` | bool | | Default `false` — force CVV entry |
| `enable_email` | bool | ✓ req_auth | Required `true` when `authorization_type=request_auth` |
| `enable_sms` | bool | | Send authorization / notification by SMS |
| `shipping_fee` | int | | Shipping fee in cents |
| `line_items` | array | | Each item needs `title` (required), `unit_price` (cents), `quantity` |

---

## 12. Customer Contacts

Customers can have multiple **contact records** — additional individuals associated with an account (e.g. a company's billing contact, a secondary signatory).

### Contact Fields

| Field | Type | Description |
|-------|------|-------------|
| `first_name` / `last_name` | string | Contact's name |
| `email` | string | Contact email address |
| `phone` | string | Primary phone number |
| `office_phone` | string | Office phone number |
| `title` | string | Job title (e.g. `CFO`, `Billing Manager`) |
| `relation` | string | Relationship to customer (`primary`, `billing`, `secondary`) |
| `is_active` | bool | Set `false` to deactivate without deleting |

### Scope Requirements

| Operation | Required Scope |
|-----------|---------------|
| List / Get contact | `payments:read` |
| Create / Update contact | `payments:write` |
| Delete contact | `payments:write` |

### Quick Reference

```http
# List contacts
GET /api/v1/customers/{customer_id}/contacts?page=1&per_page=10
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxx

# Add a contact
POST /api/v1/customers/{customer_id}/contacts
Content-Type: application/json
{
  "first_name": "Bob",
  "last_name": "Jones",
  "email": "bob.jones@example.com",
  "title": "CFO",
  "relation": "billing"
}

# Update a contact
PUT /api/v1/customers/{customer_id}/contacts/{contact_id}
{ "title": "Director of Finance" }

# Remove a contact
DELETE /api/v1/customers/{customer_id}/contacts/{contact_id}
```

---

## 13. Document Downloads

### Transaction Receipt PDF

Download a branded receipt PDF for any approved transaction. The receipt includes the merchant logo, color theme, itemized line items, and payment details.

```http
GET /api/v1/transactions/{txn_literal}/receipt
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxx
```

| Detail | Value |
|--------|-------|
| **Scope** | `payments:read` |
| **Response content-type** | `application/pdf` |
| **`txn_literal`** | The `txn_id` or `id` field returned in `GET /transactions` items |

**Code example (Python):**
```python
import requests

resp = requests.get(
    f"https://api.hubwallet.com/api/v1/transactions/{txn_literal}/receipt",
    headers={"X-API-Key": API_KEY},
    stream=True,
)
with open("receipt.pdf", "wb") as f:
    for chunk in resp.iter_content(chunk_size=8192):
        f.write(chunk)
```

---

### Invoice PDF

Download a formatted invoice PDF. Invoices are auto-generated for every completed transaction and linked to the transaction record.

```http
GET /api/v1/invoices/{invoice_literal}/pdf
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxx
```

| Detail | Value |
|--------|-------|
| **Scope** | `payments:read` |
| **Response content-type** | `application/pdf` |
| **`invoice_literal`** | Found in `GET /transactions/{id}` response under `invoices[0].invoice_literal` |

**Lookup pattern:**
```python
# 1. Get the transaction
txn = requests.get(
    f"https://api.hubwallet.com/api/v1/transactions/{txn_id}",
    headers={"X-API-Key": API_KEY},
).json()["data"]

# 2. Get the invoice literal
invoice_literal = txn["invoices"][0]["invoice_literal"]

# 3. Download the PDF
pdf = requests.get(
    f"https://api.hubwallet.com/api/v1/invoices/{invoice_literal}/pdf",
    headers={"X-API-Key": API_KEY},
    stream=True,
)
with open("invoice.pdf", "wb") as f:
    for chunk in pdf.iter_content(chunk_size=8192):
        f.write(chunk)
```

### Get Invoice Details (JSON)

Retrieve the full invoice record as JSON before downloading the PDF.

```http
GET /api/v1/invoices/{invoice_literal}
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxx
```

Returns the invoice with fields: `invoice_literal`, `invoice_number`, `status`, `amount`, `currency`, `due_date`, `line_items`, `customer`, and associated `transactions`.
"""


@portal_router.get("/developer-portal", response_class=HTMLResponse, include_in_schema=False)
async def developer_portal_ui():
    """Serve the developer documentation portal with ReDoc."""
    html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>HubWallet Developer Portal</title>
  <style>
    body { margin: 0; padding: 0; font-family: Inter, sans-serif; }
    .hw-header {
      background: #1a1a2e;
      color: white;
      padding: 16px 40px;
      display: flex;
      align-items: center;
      gap: 16px;
      position: sticky;
      top: 0;
      z-index: 100;
      box-shadow: 0 2px 8px rgba(0,0,0,0.3);
    }
    .hw-header h1 { font-size: 20px; margin: 0; font-weight: 600; }
    .hw-header .subtitle { font-size: 13px; color: #94a3b8; }
    .hw-header .badge {
      background: #28b4ed22;
      color: #28b4ed;
      border: 1px solid #28b4ed55;
      border-radius: 6px;
      padding: 3px 10px;
      font-size: 12px;
      font-weight: 500;
    }
    .hw-header .spacer { flex: 1; }
    .hw-header .try-btn {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: #28b4ed;
      color: #fff;
      border: none;
      border-radius: 8px;
      padding: 8px 16px;
      font-size: 13px;
      font-weight: 600;
      cursor: pointer;
      text-decoration: none;
      transition: background 0.15s;
    }
    .hw-header .try-btn:hover { background: #1a9fd4; }
    /* Back-to-top button */
    #back-to-top {
      position: fixed;
      bottom: 32px;
      right: 32px;
      z-index: 9999;
      width: 44px;
      height: 44px;
      border-radius: 50%;
      background: #28b4ed;
      color: #fff;
      border: none;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      box-shadow: 0 4px 14px rgba(40,180,237,0.45);
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.25s, transform 0.25s;
      transform: translateY(12px);
    }
    #back-to-top.visible {
      opacity: 1;
      pointer-events: auto;
      transform: translateY(0);
    }
    #back-to-top:hover { background: #1a9fd4; }
  </style>
</head>
<body>
  <button id="back-to-top" title="Back to top" onclick="window.scrollTo({top:0,behavior:'smooth'})">
    <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
    </svg>
  </button>
  <div class="hw-header">
    <h1>HubWallet Developer Portal</h1>
    <span class="subtitle">Payment API Reference</span>
    <span class="badge">v1.0</span>
    <div class="spacer"></div>
    <a href="/developer-portal/try" target="_blank" class="try-btn">
      <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
        <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
      </svg>
      Try it out
    </a>
  </div>
  <redoc spec-url="/developer-portal/openapi.json"
         expand-responses="200,201"
         hide-download-button
         theme='{
           "colors": {
             "primary": { "main": "#28b4ed" },
             "tonalOffset": 0.2
           },
           "typography": {
             "fontSize": "14px",
             "fontFamily": "Inter, sans-serif",
             "headings": { "fontFamily": "Inter, sans-serif", "fontWeight": "600" },
             "code": { "fontFamily": "Noto Sans Mono, monospace", "fontSize": "13px" }
           },
           "sidebar": {
             "width": "280px",
             "backgroundColor": "#1a1a2e",
             "textColor": "#cbd5e1"
           },
           "rightPanel": {
             "backgroundColor": "#0f172a"
           }
         }'
  ></redoc>
  <script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
  <script>
    (function() {
      var btn = document.getElementById('back-to-top');
      window.addEventListener('scroll', function() {
        if (window.scrollY > 400) {
          btn.classList.add('visible');
        } else {
          btn.classList.remove('visible');
        }
      }, { passive: true });
    })();
  </script>
</body>
</html>"""
    return HTMLResponse(content=html)


@portal_router.get("/developer-portal/try", response_class=HTMLResponse, include_in_schema=False)
async def developer_portal_try():
    """Swagger UI for interactive API testing — linked from the ReDoc portal."""
    html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>HubWallet API — Try it out</title>
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
  <style>
    body { margin: 0; background: #f8fafc; font-family: Inter, sans-serif; }
    .hw-header {
      background: #1a1a2e;
      color: white;
      padding: 16px 40px;
      display: flex;
      align-items: center;
      gap: 16px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.3);
    }
    .hw-header h1 { font-size: 20px; margin: 0; font-weight: 600; }
    .hw-header .subtitle { font-size: 13px; color: #94a3b8; }
    .hw-header .spacer { flex: 1; }
    .hw-header .back-btn {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      color: #94a3b8;
      font-size: 13px;
      text-decoration: none;
      transition: color 0.15s;
    }
    .hw-header .back-btn:hover { color: #fff; }
    #swagger-ui { max-width: 1400px; margin: 0 auto; padding: 20px; }
    .swagger-ui .topbar { display: none; }
  </style>
</head>
<body>
  <div class="hw-header">
    <h1>HubWallet API</h1>
    <span class="subtitle">Interactive Playground</span>
    <div class="spacer"></div>
    <a href="/developer-portal" class="back-btn">
      <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
      </svg>
      Back to Docs
    </a>
  </div>
  <div id="swagger-ui"></div>
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
  <script>
    SwaggerUIBundle({
      url: "/developer-portal/openapi-try.json",
      dom_id: "#swagger-ui",
      presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
      layout: "BaseLayout",
      persistAuthorization: true,
      deepLinking: true,
      tryItOutEnabled: true,
      docExpansion: "list",
      defaultModelsExpandDepth: -1,
    });
  </script>
</body>
</html>"""
    return HTMLResponse(content=html)


# ─── Operation documentation injected into the OpenAPI spec ──────────────────
# Keyed by (HTTP_METHOD_UPPERCASE, path_as_in_openapi_spec).
# Overrides or fills in summary + description for every exposed endpoint.
_OPERATION_DOCS: dict = {
    # ── Products ────────────────────────────────────────────────────────────
    ("GET", "/api/v1/products"): {
        "summary": "List Products",
        "description": (
            "Returns a paginated list of active products for the authenticated merchant.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Query parameters:**\n"
            "- `page` (int, default 1) — Page number\n"
            "- `page_size` (int, default 20, max 100) — Results per page\n"
            "- `search` (string) — Filter by product name or SKU\n"
            "- `is_active` (bool) — Filter by active status\n\n"
            "**Response envelope:**\n"
            "```json\n{ \"data\": { \"items\": [...], \"total\": 0, \"page\": 1, \"page_size\": 20 } }\n```"
        ),
    },
    # ── Customers ───────────────────────────────────────────────────────────
    ("GET", "/api/v1/customers"): {
        "summary": "List Customers",
        "description": (
            "Returns a paginated list of customers belonging to the authenticated merchant.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Query parameters:**\n"
            "- `page` / `page_size` — Pagination\n"
            "- `search` (string) — Filter by name, email, or phone\n"
            "- `is_active` (bool) — Filter by active status\n"
            "- `account_type` (string) — `individual` or `business`\n\n"
            "**Response envelope:**\n"
            "```json\n{ \"data\": { \"items\": [...], \"total\": 0, \"page\": 1, \"page_size\": 20 } }\n```"
        ),
    },
    ("GET", "/api/v1/customers/{customer_id}"): {
        "summary": "Get Customer",
        "description": (
            "Retrieve full details for a single customer by their integer ID.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `customer_id` (int) — The customer's primary key (`id` from the list endpoint)\n\n"
            "**Key response fields:**\n"
            "- `account_literal` — The unique alphanumeric customer identifier (e.g. `ACCDRE954419A09`). "
            "This is the value to pass as `customer_id` when creating a payment request.\n"
            "- `default_address` — Default billing/shipping address\n"
            "- `contacts` — Associated contact records\n\n"
            "Returns **404** if the customer does not exist or belongs to a different merchant."
        ),
    },
    # ── Payment Requests ────────────────────────────────────────────────────
    ("GET", "/api/v1/payment-requests"): {
        "summary": "List Payment Requests",
        "description": (
            "Returns a paginated list of payment requests created by the authenticated merchant.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Query parameters:**\n"
            "- `page` / `page_size` — Pagination\n"
            "- `status` (string) — Filter by status: `pending`, `completed`, `cancelled`, `failed`\n"
            "- `date_from` / `date_to` (ISO 8601) — Filter by creation date range\n"
            "- `search` (string) — Filter by reference, description, or customer name\n\n"
            "**Response envelope:**\n"
            "```json\n{ \"data\": { \"items\": [...], \"total\": 0, \"page\": 1, \"page_size\": 20 } }\n```"
        ),
    },
    ("POST", "/api/v1/payment-requests"): {
        "summary": "Create Payment Request",
        "description": (
            "Creates a new payment request and returns a hosted payment URL to share with your customer.\n\n"
            "**Scope required:** `payments:write`\n\n"
            "**Required fields:**\n"
            "- `amount` (int, required) — Charge amount **in cents** (e.g. `15000` = $150.00)\n"
            "- `currency` (string, required) — Lowercase ISO 4217 code: `usd`, `aud`, `eur`, etc.\n"
            "- `customer_id` (string, required) — Customer literal ID (e.g. `ACCDRE954419A09`) from `GET /customers`\n"
            "- `payment_frequency` (string, required) — `one_time`, `recurring`, or `split`\n"
            "- `authorization_type` (string, required) — `pre_auth` or `request_auth`\n"
            "- `due_date` (ISO 8601, required for `one_time`) — Payment deadline\n\n"
            "**Optional fields:**\n"
            "- `description` (string) — Shown on the payment page and receipt\n"
            "- `reference` (string) — Your internal reference (invoice number, order ID)\n"
            "- `require_billing_address` (bool) — Force the customer to enter a billing address\n"
            "- `require_cvv` (bool) — Force CVV entry (default `false`)\n"
            "- `enable_email` (bool) — Send payment link by email to the customer\n"
            "  > **Required `true` when `authorization_type=request_auth`**\n"
            "- `shipping_fee` (int) — Shipping fee in cents\n"
            "- `line_items` (array) — Each item needs `title` (required), `unit_price` (cents), `quantity`\n\n"
            "**Response** includes `payment_url` — the hosted payment page link to share.\n\n"
            "> **Idempotency:** Send `X-Idempotency-Key: <uuid>` to safely retry without creating duplicate charges."
        ),
    },
    # ── Transactions ────────────────────────────────────────────────────────
    ("GET", "/api/v1/transactions"): {
        "summary": "List Transactions",
        "description": (
            "Returns a paginated list of transactions for the authenticated merchant, ordered by most recent first.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Query parameters:**\n"
            "- `page` / `page_size` — Pagination (max 200)\n"
            "- `status` (string) — `approved`, `declined`, `pending`, `refunded`, `voided`\n"
            "- `txn_type` (string) — `sale`, `refund`, `void`, `auth`\n"
            "- `date_from` / `date_to` (ISO 8601) — Filter by transaction date\n"
            "- `search` (string) — Filter by customer name, reference, or transaction ID\n\n"
            "**Response envelope:**\n"
            "```json\n{ \"data\": { \"items\": [...], \"total\": 0, \"page\": 1, \"page_size\": 50 } }\n```\n\n"
            "Each item includes: `txn_id`, `txn_amount`, `txn_status`, `txn_type`, `currency`, "
            "`billing_name`, `refundable_balance`, and `ocurred_at`."
        ),
    },
    ("GET", "/api/v1/transactions/summary"): {
        "summary": "Get Transaction Summary",
        "description": (
            "Returns aggregated statistics for the authenticated merchant's transactions.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Query parameters:**\n"
            "- `date_from` / `date_to` (ISO 8601) — Limit summary to a date range\n"
            "- `currency` (string) — Filter by currency code\n\n"
            "**Response fields:**\n"
            "- `total_volume` — Sum of all approved transaction amounts\n"
            "- `total_count` — Total number of transactions in range\n"
            "- `approved_count` / `declined_count` — Breakdown by outcome\n"
            "- `refunded_amount` — Total amount refunded\n"
            "- `net_volume` — `total_volume - refunded_amount`"
        ),
    },
    ("GET", "/api/v1/transactions/{transaction_id}"): {
        "summary": "Get Transaction",
        "description": (
            "Retrieve full details for a single transaction by its integer ID.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `transaction_id` (int) — The transaction's primary key (use `id` from the list endpoint)\n\n"
            "**Response includes:**\n"
            "- `payment_method` — Masked card info (`card_brand`, `last_four`, `exp_month`, `exp_year`)\n"
            "- `billing_address` — Address used at time of payment\n"
            "- `payment_request` — The linked payment request, if any\n"
            "- `invoices` — Auto-generated invoices linked to this transaction\n"
            "- `refundable_balance` — Amount still eligible for refund\n\n"
            "Returns **404** if the transaction does not exist or belongs to a different merchant."
        ),
    },
    ("POST", "/api/v1/transactions/{transaction_id}/refund"): {
        "summary": "Refund Transaction",
        "description": (
            "Issues a full or partial refund for an approved transaction.\n\n"
            "**Scope required:** `payments:refund`\n\n"
            "**Path parameter:**\n"
            "- `transaction_id` (int) — ID of the original transaction to refund\n\n"
            "**Request body:**\n"
            "- `amount` (float, required) — Refund amount. Must not exceed `refundable_balance`.\n"
            "- `reason` (string, optional) — Internal note explaining the refund\n\n"
            "**Behaviour:**\n"
            "- A new transaction of type `refund` is created and linked to the original\n"
            "- `refundable_balance` on the original transaction is decreased accordingly\n"
            "- Multiple partial refunds are allowed until `refundable_balance` reaches 0\n\n"
            "Returns **400** if `amount` exceeds the remaining `refundable_balance`.\n"
            "Returns **404** if the transaction does not exist or belongs to a different merchant."
        ),
    },
    # ── Developer — API Keys ─────────────────────────────────────────────────
    ("GET", "/api/v1/developer/api-keys"): {
        "summary": "List API Keys",
        "description": (
            "Returns all API keys created for the authenticated merchant's account, including revoked keys.\n\n"
            "**Note:** The full secret key (`sk_live_...` / `sk_test_...`) is only returned at creation time. "
            "This endpoint returns only the `key_prefix` (first 12 characters) and metadata.\n\n"
            "**Response fields per key:**\n"
            "- `id` — Internal database ID (used in update/revoke endpoints)\n"
            "- `key_id` — Stable public identifier for the key\n"
            "- `key_prefix` — First characters of the secret key for recognition\n"
            "- `display_name` — Human-readable label\n"
            "- `environment` — `live` or `test`\n"
            "- `scopes` — List of granted permission scopes\n"
            "- `is_active` — `false` if the key has been revoked\n"
            "- `last_used_at` — Timestamp of most recent authenticated request\n"
            "- `expires_at` — Expiry timestamp, or `null` if the key never expires"
        ),
    },
    ("POST", "/api/v1/developer/api-keys"): {
        "summary": "Create API Key",
        "description": (
            "Generates a new API key for the authenticated merchant.\n\n"
            "> **Important:** The full secret key is returned **only once** in this response. "
            "Store it securely — it cannot be retrieved again. If lost, revoke and create a new key.\n\n"
            "**Request body:**\n"
            "- `display_name` (string, required) — A label to identify this key (e.g. `Backend Server`)\n"
            "- `environment` (string, required) — `live` for real payments, `test` for testing\n"
            "- `scopes` (array, required) — Permission scopes to grant:\n"
            "  - `payments:read` — Read transactions, customers, products\n"
            "  - `payments:write` — Create payment requests\n"
            "  - `payments:refund` — Issue refunds\n"
            "- `allowed_ips` (array, optional) — Restrict usage to specific IP addresses\n"
            "- `expires_at` (ISO 8601, optional) — Auto-expire the key at a future date\n\n"
            "**Best practice:** Issue keys with the minimum required scopes."
        ),
    },
    ("GET", "/api/v1/developer/api-keys/{key_id}"): {
        "summary": "Get API Key",
        "description": (
            "Retrieve metadata for a single API key by its integer database ID.\n\n"
            "**Path parameter:**\n"
            "- `key_id` (int) — The `id` field from the List API Keys response\n\n"
            "Returns the same fields as the list endpoint. The full secret key is never returned here."
        ),
    },
    ("PUT", "/api/v1/developer/api-keys/{key_id}"): {
        "summary": "Update API Key",
        "description": (
            "Update mutable properties of an existing API key.\n\n"
            "**Path parameter:**\n"
            "- `key_id` (int) — The `id` field from the List API Keys response\n\n"
            "**Updatable fields:**\n"
            "- `display_name` (string) — Rename the key\n"
            "- `scopes` (array) — Replace the key's scopes\n"
            "- `allowed_ips` (array) — Update IP allowlist; pass `[]` to remove restrictions\n"
            "- `is_active` (bool) — `false` to temporarily disable without permanent revocation\n"
            "- `expires_at` (ISO 8601 or null) — Set or clear the expiry date\n\n"
            "Only fields included in the request body are updated (partial update / PATCH semantics)."
        ),
    },
    ("DELETE", "/api/v1/developer/api-keys/{key_id}"): {
        "summary": "Revoke API Key",
        "description": (
            "Permanently revokes an API key. Any subsequent request using this key will receive **HTTP 401**.\n\n"
            "**Path parameter:**\n"
            "- `key_id` (int) — The `id` field from the List API Keys response\n\n"
            "> **This action is irreversible.** Revoked keys cannot be reinstated. "
            "Create a new key if access needs to be restored.\n\n"
            "Returns **204 No Content** on success."
        ),
    },
    # ── Developer — Webhooks ─────────────────────────────────────────────────
    ("GET", "/api/v1/developer/webhooks"): {
        "summary": "List Webhook Endpoints",
        "description": (
            "Returns all registered webhook endpoints for the authenticated merchant.\n\n"
            "**Response fields per endpoint:**\n"
            "- `id` — Internal ID (used in update/delete/delivery endpoints)\n"
            "- `display_name` — Human-readable label\n"
            "- `url` — HTTPS URL that receives webhook events\n"
            "- `events` — List of subscribed event types\n"
            "- `is_active` — `false` if the endpoint has been disabled (e.g. after 10 consecutive failures)\n"
            "- `created_at` — Registration timestamp\n\n"
            "**Note:** The signing secret is never returned after creation. Use the rotate-secret endpoint to issue a new one."
        ),
    },
    ("POST", "/api/v1/developer/webhooks"): {
        "summary": "Create Webhook Endpoint",
        "description": (
            "Registers a new HTTPS endpoint to receive webhook event notifications.\n\n"
            "> **Important:** The `signing_secret` is returned **only once**. "
            "Store it immediately — it is used to verify the `HubWallet-Signature` header on every delivery.\n\n"
            "**Request body:**\n"
            "- `display_name` (string, required) — A label for this endpoint\n"
            "- `url` (string, required) — A publicly reachable HTTPS URL. HTTP is not accepted.\n"
            "- `events` (array, required) — Event types to subscribe to. Supported values:\n"
            "  - `transaction.completed`\n"
            "  - `transaction.failed`\n"
            "  - `payment_request.created`\n"
            "  - `payment_request.completed`\n\n"
            "**Signature verification** (recommended):\n"
            "```python\n"
            "import hmac, hashlib\n"
            "sig_header = request.headers['HubWallet-Signature']  # t=...,v1=...\n"
            "parts = dict(p.split('=') for p in sig_header.split(','))\n"
            "payload = f\"{parts['t']}.{raw_body}\"\n"
            "expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()\n"
            "assert hmac.compare_digest(expected, parts['v1'])\n"
            "```"
        ),
    },
    ("PUT", "/api/v1/developer/webhooks/{endpoint_id}"): {
        "summary": "Update Webhook Endpoint",
        "description": (
            "Update a registered webhook endpoint.\n\n"
            "**Path parameter:**\n"
            "- `endpoint_id` (int) — The `id` field from the List Webhooks response\n\n"
            "**Updatable fields:**\n"
            "- `display_name` (string) — Rename the endpoint\n"
            "- `url` (string) — Change the destination URL\n"
            "- `events` (array) — Replace the subscribed event list\n"
            "- `is_active` (bool) — Re-enable a disabled endpoint by setting to `true`\n\n"
            "Only included fields are updated."
        ),
    },
    ("DELETE", "/api/v1/developer/webhooks/{endpoint_id}"): {
        "summary": "Delete Webhook Endpoint",
        "description": (
            "Permanently deletes a webhook endpoint. No further events will be delivered to it.\n\n"
            "**Path parameter:**\n"
            "- `endpoint_id` (int) — The `id` field from the List Webhooks response\n\n"
            "Returns **204 No Content** on success."
        ),
    },
    ("POST", "/api/v1/developer/webhooks/{endpoint_id}/rotate-secret"): {
        "summary": "Rotate Webhook Signing Secret",
        "description": (
            "Generates a new signing secret for the webhook endpoint, invalidating the previous one.\n\n"
            "**Path parameter:**\n"
            "- `endpoint_id` (int) — The `id` field from the List Webhooks response\n\n"
            "> The new `signing_secret` is returned **only once** in this response. "
            "Update your server immediately — deliveries signed with the old secret will fail verification.\n\n"
            "**Use this endpoint when:**\n"
            "- You suspect the signing secret has been compromised\n"
            "- You are rotating secrets as part of a security policy"
        ),
    },
    ("GET", "/api/v1/developer/webhooks/{endpoint_id}/deliveries"): {
        "summary": "List Webhook Deliveries",
        "description": (
            "Returns the delivery history for a specific webhook endpoint, most recent first.\n\n"
            "**Path parameter:**\n"
            "- `endpoint_id` (int) — The `id` field from the List Webhooks response\n\n"
            "**Query parameters:**\n"
            "- `page` / `page_size` — Pagination (max 100)\n\n"
            "**Response fields per delivery:**\n"
            "- `event_type` — Event that triggered the delivery. One of:\n"
            "  `transaction.completed`, `transaction.failed`, `transaction.refunded`,\n"
            "  `payment_request.created`, `payment_request.updated`, `payment_request.cancelled`\n"
            "- `event_id` — Unique identifier for the event\n"
            "- `payload` — The exact JSON body that was POSTed to your endpoint, including `event_type`, `event_id`, `timestamp`, `merchant_id`, and a `data` object specific to the event type\n"
            "- `response_status` — HTTP status code your endpoint returned (null if never reached)\n"
            "- `response_body` — First 1000 chars of your endpoint's response body\n"
            "- `attempt_count` — Number of delivery attempts made (max 5 with exponential backoff)\n"
            "- `delivered_at` — Timestamp of successful delivery (`null` if still failing)\n"
            "- `failed_at` — Timestamp of the most recent failed attempt\n\n"
            "**Retry schedule:** 1 min → 5 min → 30 min → 2 hr → 2 hr\n\n"
            "**Auto-disable:** After 10 consecutive failures the endpoint is disabled. "
            "Re-enable via the Update Webhook endpoint (`is_active: true`).\n\n"
            "See the **Webhooks** section in the introduction for complete event payload schemas."
        ),
    },
    ("GET", "/api/v1/developer/logs"): {
        "summary": "List API Request Logs",
        "description": (
            "Returns a log of recent API requests made using your API keys, most recent first.\n\n"
            "Useful for debugging integration issues, auditing access, and monitoring usage patterns.\n\n"
            "**Query parameters:**\n"
            "- `api_key_id` (int) — Filter logs for a specific key\n"
            "- `status_code` (int) — Filter by HTTP response code (e.g. `400`, `500`)\n"
            "- `method` (string) — Filter by HTTP method: `GET`, `POST`, `PUT`, `DELETE`\n"
            "- `date_from` / `date_to` (ISO 8601) — Filter by request timestamp\n"
            "- `page` / `page_size` — Pagination (max 200)\n\n"
            "**Response fields per log entry:**\n"
            "- `key_id` — Public identifier of the API key used\n"
            "- `method` / `path` — Request method and path\n"
            "- `status_code` — HTTP response code returned\n"
            "- `duration_ms` — Server-side processing time in milliseconds\n"
            "- `ip_address` — IP address the request originated from\n"
            "- `request_id` — Unique request identifier (include in support tickets)\n"
            "- `error_message` — Error detail for non-2xx responses"
        ),
    },
    # ── Customers — Create ───────────────────────────────────────────────────
    ("POST", "/api/v1/customers"): {
        "summary": "Create Customer",
        "description": (
            "Creates a new customer record for the authenticated merchant.\n\n"
            "**Scope required:** `payments:write`\n\n"
            "**Request body:**\n"
            "- `first_name` / `last_name` (string) — Customer name\n"
            "- `email` (string) — Primary contact email\n"
            "- `phone` (string) — Phone number (E.164 format recommended, e.g. `+15550100`)\n"
            "- `account_type` (string) — `individual` (default) or `business`\n"
            "- `business_legal_name` (string) — Recommended when `account_type=business`\n"
            "- `account_literal` (string) — Optional custom ID. If omitted, HubWallet auto-generates one.\n\n"
            "**Response** includes `account_literal` (e.g. `ACCDRE954419A09`) — the unique alphanumeric "
            "customer identifier. Pass this value as `customer_id` in `POST /payment-requests`.\n\n"
            "> **To create a payment request:** use the `account_literal` from this response as the "
            "`customer_id` field in `POST /payment-requests`."
        ),
    },
    # ── Customer Contacts ────────────────────────────────────────────────────
    ("GET", "/api/v1/customers/{customer_id}/contacts"): {
        "summary": "List Customer Contacts",
        "description": (
            "Returns a paginated list of contact records for the specified customer.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `customer_id` (int) — The customer's database ID\n\n"
            "**Query parameters:**\n"
            "- `page` / `per_page` — Pagination (default page=1, per_page=10, max 100)\n"
            "- `search` (string) — Filter by name, email, title, or phone\n"
            "- `relation` (string) — Filter by relation type (e.g. `primary`, `billing`)\n"
            "- `is_active` (bool) — Filter by active status\n\n"
            "**Response fields per contact:**\n"
            "- `id` — Contact database ID (used in update/delete endpoints)\n"
            "- `first_name` / `last_name` — Contact name\n"
            "- `email` — Contact email\n"
            "- `phone` / `office_phone` — Contact phone numbers\n"
            "- `title` — Job title\n"
            "- `relation` — Relationship to the customer account\n"
            "- `is_active` — Whether the contact is active"
        ),
    },
    ("POST", "/api/v1/customers/{customer_id}/contacts"): {
        "summary": "Create Customer Contact",
        "description": (
            "Adds a new contact record to an existing customer.\n\n"
            "**Scope required:** `payments:write`\n\n"
            "**Path parameter:**\n"
            "- `customer_id` (int) — The customer's database ID\n\n"
            "**Request body:**\n"
            "- `first_name` / `last_name` (string) — Contact name\n"
            "- `email` (string) — Contact email address\n"
            "- `phone` (string) — Primary phone number\n"
            "- `office_phone` (string) — Office / work phone\n"
            "- `title` (string) — Job title (e.g. `CFO`, `Billing Manager`)\n"
            "- `relation` (string) — Relationship to the customer (`primary`, `billing`, `secondary`, etc.)\n\n"
            "Returns **201 Created** with the new contact object."
        ),
    },
    ("GET", "/api/v1/customers/{customer_id}/contacts/{contact_id}"): {
        "summary": "Get Customer Contact",
        "description": (
            "Retrieve a single contact record by ID.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameters:**\n"
            "- `customer_id` (int) — The customer's database ID\n"
            "- `contact_id` (int) — The contact's database ID\n\n"
            "Returns **404** if the contact does not exist or belongs to a different customer/merchant."
        ),
    },
    ("PUT", "/api/v1/customers/{customer_id}/contacts/{contact_id}"): {
        "summary": "Update Customer Contact",
        "description": (
            "Update an existing contact record. Only included fields are updated (partial update semantics).\n\n"
            "**Scope required:** `payments:write`\n\n"
            "**Path parameters:**\n"
            "- `customer_id` (int) — The customer's database ID\n"
            "- `contact_id` (int) — The contact's database ID\n\n"
            "**Updatable fields:**\n"
            "- `first_name` / `last_name` — Update the contact name\n"
            "- `email` — Update the contact email\n"
            "- `phone` / `office_phone` — Update phone numbers\n"
            "- `title` / `relation` — Update job title or relationship type\n"
            "- `is_active` — Set to `false` to deactivate without deleting"
        ),
    },
    ("DELETE", "/api/v1/customers/{customer_id}/contacts/{contact_id}"): {
        "summary": "Delete Customer Contact",
        "description": (
            "Permanently removes a contact record.\n\n"
            "**Scope required:** `payments:write`\n\n"
            "**Path parameters:**\n"
            "- `customer_id` (int) — The customer's database ID\n"
            "- `contact_id` (int) — The contact's database ID\n\n"
            "Returns **204 No Content** on success.\n\n"
            "> To temporarily deactivate a contact without deleting it, use `PUT` with `{ \"is_active\": false }` instead."
        ),
    },
    # ── Transaction — Receipt Download ───────────────────────────────────────
    ("GET", "/api/v1/transactions/{txn_literal}/receipt"): {
        "summary": "Download Transaction Receipt (PDF)",
        "description": (
            "Returns a branded PDF receipt for the specified transaction.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `txn_literal` (string) — The `txn_id` or `id` from the transaction object "
            "(e.g. `txn_4kQmXr9LpW` or the integer `id`)\n\n"
            "**Response:** `application/pdf` binary stream — save directly to a file or stream to the client.\n\n"
            "The receipt is branded with your merchant logo, color theme, itemized line items, "
            "payment method (masked), billing address, and transaction reference.\n\n"
            "**Python example:**\n"
            "```python\n"
            "resp = requests.get(\n"
            "    f\"{BASE_URL}/transactions/{txn_literal}/receipt\",\n"
            "    headers={\"X-API-Key\": API_KEY},\n"
            "    stream=True,\n"
            ")\n"
            "with open(\"receipt.pdf\", \"wb\") as f:\n"
            "    for chunk in resp.iter_content(chunk_size=8192):\n"
            "        f.write(chunk)\n"
            "```\n\n"
            "Returns **404** if the transaction does not exist or belongs to a different merchant."
        ),
    },
    # ── Invoices ─────────────────────────────────────────────────────────────
    ("GET", "/api/v1/invoices/{invoice_literal}"): {
        "summary": "Get Invoice",
        "description": (
            "Retrieve the full invoice record for a given invoice literal.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `invoice_literal` (string) — The invoice identifier (e.g. `INV-0042`). "
            "Found in `GET /transactions/{id}` under `invoices[0].invoice_literal`.\n\n"
            "**Response includes:**\n"
            "- `invoice_literal` — Human-readable invoice reference (e.g. `INV-0042`)\n"
            "- `invoice_number` — Sequential number\n"
            "- `status` — `draft`, `pending`, `paid`, `failed`, `cancelled`, `overdue`\n"
            "- `amount` / `currency` — Invoice amount and currency\n"
            "- `due_date` — Payment deadline\n"
            "- `line_items` — Itemized breakdown\n"
            "- `customer` — Linked customer record\n"
            "- `transactions` — Linked transaction records\n\n"
            "Returns **404** if the invoice does not exist or belongs to a different merchant."
        ),
    },
    ("GET", "/api/v1/invoices/{invoice_literal}/pdf"): {
        "summary": "Download Invoice PDF",
        "description": (
            "Returns a formatted PDF invoice for the specified invoice.\n\n"
            "**Scope required:** `payments:read`\n\n"
            "**Path parameter:**\n"
            "- `invoice_literal` (string) — The invoice identifier (e.g. `INV-0042`). "
            "Found in `GET /transactions/{id}` under `invoices[0].invoice_literal`, "
            "or from `GET /invoices/{invoice_literal}`.\n\n"
            "**Response:** `application/pdf` binary stream — save to a file or stream to the client.\n\n"
            "The invoice PDF includes itemized line items, customer details, payment terms, "
            "merchant branding, and a payment summary.\n\n"
            "**Lookup pattern:**\n"
            "```python\n"
            "# Step 1: get the transaction and find its invoice\n"
            "txn = requests.get(f\"{BASE_URL}/transactions/{txn_id}\",\n"
            "                   headers={\"X-API-Key\": API_KEY}).json()[\"data\"]\n"
            "invoice_literal = txn[\"invoices\"][0][\"invoice_literal\"]\n\n"
            "# Step 2: download the PDF\n"
            "pdf = requests.get(f\"{BASE_URL}/invoices/{invoice_literal}/pdf\",\n"
            "                   headers={\"X-API-Key\": API_KEY}, stream=True)\n"
            "with open(\"invoice.pdf\", \"wb\") as f:\n"
            "    for chunk in pdf.iter_content(8192): f.write(chunk)\n"
            "```\n\n"
            "Returns **404** if the invoice does not exist or belongs to a different merchant."
        ),
    },
}


# ─── Realistic examples injected into the OpenAPI spec ───────────────────────
# Keyed by (HTTP_METHOD_UPPERCASE, path_as_in_openapi_spec).
# "response" → injected into the 200/201 response body example.
# "request"  → injected into the requestBody example.
_EXAMPLES: dict = {
    # ── Products ────────────────────────────────────────────────────────────
    ("GET", "/api/v1/products"): {
        "response": {
            "data": {
                "items": [
                    {
                        "id": 1,
                        "name": "Premium Widget",
                        "sku": "WDG-001",
                        "slug": "premium-widget",
                        "unit_price": 49.99,
                        "sale_price": 39.99,
                        "description": "A high-quality widget suitable for all use cases.",
                        "is_active": True,
                        "is_new": False,
                        "item_type": "physical",
                        "is_sell": True,
                        "calculated_sell_tax_rate": 8.5,
                        "calculated_purchase_tax_rate": 0.0,
                        "category_id": 3,
                        "merchant_id": 12,
                        "created_at": "2026-01-15T10:30:00Z",
                        "updated_at": "2026-03-20T08:15:00Z"
                    }
                ],
                "total": 1,
                "page": 1,
                "page_size": 20
            },
            "success": True,
            "message": None
        }
    },
    # ── Customers ───────────────────────────────────────────────────────────
    ("GET", "/api/v1/customers"): {
        "response": {
            "data": {
                "items": [
                    {
                        "id": 5,
                        "account_literal": "ACCDRE954419A09",
                        "first_name": "Jane",
                        "last_name": "Smith",
                        "email": "jane.smith@example.com",
                        "phone": "+15550100",
                        "business_legal_name": "Smith Enterprises LLC",
                        "account_type": "business",
                        "is_active": True,
                        "created_at": "2026-02-01T09:00:00Z"
                    }
                ],
                "total": 1,
                "page": 1,
                "page_size": 20
            },
            "success": True,
            "message": None
        }
    },
    ("GET", "/api/v1/customers/{customer_id}"): {
        "response": {
            "data": {
                "id": 5,
                "account_literal": "ACCDRE954419A09",
                "first_name": "Jane",
                "last_name": "Smith",
                "email": "jane.smith@example.com",
                "phone": "+15550100",
                "business_legal_name": "Smith Enterprises LLC",
                "account_type": "business",
                "is_active": True,
                "default_address": {
                    "address_line_1": "123 Main St",
                    "city": "Austin",
                    "state": "TX",
                    "zipcode": "78701",
                    "country": "US"
                },
                "contacts": [],
                "created_at": "2026-02-01T09:00:00Z",
                "updated_at": "2026-03-10T14:22:00Z"
            },
            "success": True,
            "message": None
        }
    },
    # ── Payment Requests ────────────────────────────────────────────────────
    ("POST", "/api/v1/payment-requests"): {
        "request": {
            "amount": 15000,
            "currency": "usd",
            "customer_id": "ACCDRE954419A09",
            "payment_frequency": "one_time",
            "authorization_type": "pre_auth",
            "due_date": "2026-05-30T00:00:00Z",
            "description": "Invoice #2026-042 — Web Design Services",
            "reference": "INV-2026-042",
            "require_billing_address": True,
            "require_cvv": True,
            "enable_email": True,
            "enable_email_receipt": True,
            "shipping_fee": 0,
            "line_items": [
                {
                    "title": "Web Design",
                    "description": "8 hrs @ $18.75",
                    "unit_price": 1875,
                    "quantity": 8
                }
            ]
        },
        "response": {
            "data": {
                "id": 101,
                "payment_request_literal": "PR-101",
                "amount": 15000,
                "currency": "usd",
                "status": 103,
                "status_text": "Pending",
                "description": "Invoice #2026-042 — Web Design Services",
                "reference": "INV-2026-042",
                "payment_url": "https://pay.hubwallet.com/hpp/PR-101",
                "due_date": "2026-05-30T00:00:00Z",
                "created_at": "2026-04-08T12:00:00Z"
            },
            "success": True,
            "message": None
        }
    },
    ("GET", "/api/v1/payment-requests"): {
        "response": {
            "data": {
                "items": [
                    {
                        "id": 101,
                        "payment_request_literal": "PR-101",
                        "amount": 15000,
                        "currency": "usd",
                        "status": 500,
                        "status_text": "Paid",
                        "description": "Invoice #2026-042 — Web Design Services",
                        "reference": "INV-2026-042",
                        "created_at": "2026-04-08T12:00:00Z"
                    }
                ],
                "total": 1,
                "page": 1,
                "page_size": 20
            },
            "success": True,
            "message": None
        }
    },
    # ── Transactions ────────────────────────────────────────────────────────
    ("GET", "/api/v1/transactions"): {
        "response": {
            "data": {
                "items": [
                    {
                        "id": 88,
                        "txn_id": "txn_4kQmXr9LpW",
                        "txn_amount": 150.00,
                        "txn_status": "approved",
                        "txn_type": "sale",
                        "txn_source": "api",
                        "currency": "USD",
                        "billing_name": "Jane Smith",
                        "customer_name": "Jane Smith",
                        "refundable_balance": 150.00,
                        "reference_id": "INV-2026-042",
                        "description": "Web Design Services",
                        "ocurred_at": "2026-04-08T13:45:00Z",
                        "created_at": "2026-04-08T13:45:00Z"
                    }
                ],
                "total": 1,
                "page": 1,
                "page_size": 50
            },
            "success": True,
            "message": None
        }
    },
    ("GET", "/api/v1/transactions/summary"): {
        "response": {
            "data": {
                "total_volume": 12500.00,
                "total_count": 84,
                "approved_count": 80,
                "declined_count": 4,
                "refunded_amount": 250.00,
                "net_volume": 12250.00,
                "currency": "USD"
            },
            "success": True,
            "message": None
        }
    },
    ("GET", "/api/v1/transactions/{transaction_id}"): {
        "response": {
            "data": {
                "id": 88,
                "txn_id": "txn_4kQmXr9LpW",
                "txn_amount": 150.00,
                "txn_status": "approved",
                "txn_type": "sale",
                "txn_source": "api",
                "currency": "USD",
                "billing_name": "Jane Smith",
                "customer_name": "Jane Smith",
                "refundable_balance": 150.00,
                "reference_id": "INV-2026-042",
                "description": "Web Design Services",
                "payment_method": {
                    "card_brand": "Visa",
                    "last_four": "4242",
                    "exp_month": 12,
                    "exp_year": 2027,
                    "billing_name": "Jane Smith"
                },
                "billing_address": {
                    "line1": "123 Main St",
                    "city": "Austin",
                    "state": "TX",
                    "postal_code": "78701",
                    "country": "US"
                },
                "ocurred_at": "2026-04-08T13:45:00Z",
                "created_at": "2026-04-08T13:45:00Z"
            },
            "success": True,
            "message": None
        }
    },
    ("POST", "/api/v1/transactions/{transaction_id}/refund"): {
        "request": {
            "amount": 50.00,
            "reason": "Customer requested partial refund"
        },
        "response": {
            "data": {
                "id": 89,
                "txn_id": "txn_7rNbYs2MqV",
                "txn_amount": 50.00,
                "txn_status": "approved",
                "txn_type": "refund",
                "currency": "USD",
                "reference_id": "INV-2026-042",
                "ocurred_at": "2026-04-08T14:10:00Z"
            },
            "success": True,
            "message": "Refund processed successfully"
        }
    },
    # ── Developer — API Keys ─────────────────────────────────────────────────
    ("GET", "/api/v1/developer/api-keys"): {
        "response": [
            {
                "id": 1,
                "key_id": "key_a1b2c3d4",
                "key_prefix": "sk_live_a1b2",
                "display_name": "Production Integration",
                "environment": "live",
                "scopes": ["payments:read", "payments:write"],
                "is_active": True,
                "last_used_at": "2026-04-07T18:30:00Z",
                "created_at": "2026-01-10T09:00:00Z",
                "expires_at": None
            }
        ]
    },
    ("POST", "/api/v1/developer/api-keys"): {
        "request": {
            "display_name": "Backend Server Key",
            "environment": "live",
            "scopes": ["payments:read", "payments:write"],
            "allowed_ips": ["203.0.113.42"],
            "expires_at": None
        },
        "response": {
            "id": 2,
            "key_id": "key_e5f6g7h8",
            "api_key": "sk_live_e5f6g7h8xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "key_prefix": "sk_live_e5f6",
            "display_name": "Backend Server Key",
            "environment": "live",
            "scopes": ["payments:read", "payments:write"],
            "created_at": "2026-04-08T12:00:00Z"
        }
    },
    # ── Developer — Webhooks ─────────────────────────────────────────────────
    ("GET", "/api/v1/developer/webhooks"): {
        "response": [
            {
                "id": 1,
                "display_name": "Order Notifications",
                "url": "https://yourapp.com/webhooks/hubwallet",
                "events": ["transaction.completed", "transaction.failed"],
                "is_active": True,
                "created_at": "2026-02-14T11:00:00Z"
            }
        ]
    },
    ("POST", "/api/v1/developer/webhooks"): {
        "request": {
            "display_name": "Order Notifications",
            "url": "https://yourapp.com/webhooks/hubwallet",
            "events": ["transaction.completed", "transaction.failed"]
        },
        "response": {
            "id": 1,
            "display_name": "Order Notifications",
            "url": "https://yourapp.com/webhooks/hubwallet",
            "events": ["transaction.completed", "transaction.failed"],
            "signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "is_active": True,
            "created_at": "2026-04-08T12:00:00Z"
        }
    },
    ("POST", "/api/v1/developer/webhooks/{endpoint_id}/rotate-secret"): {
        "response": {
            "id": 1,
            "display_name": "Order Notifications",
            "url": "https://yourapp.com/webhooks/hubwallet",
            "events": ["transaction.completed", "transaction.failed"],
            "signing_secret": "whsec_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
            "is_active": True,
            "created_at": "2026-02-14T11:00:00Z"
        }
    },
    ("GET", "/api/v1/developer/webhooks/{endpoint_id}/deliveries"): {
        "response": {
            "items": [
                {
                    "id": 55,
                    "event_type": "transaction.completed",
                    "event_id": "evt_a1b2c3d4e5f6",
                    "payload": {
                        "event_type": "transaction.completed",
                        "event_id": "evt_a1b2c3d4e5f6",
                        "timestamp": 1712345678,
                        "merchant_id": 12,
                        "data": {
                            "transaction_id": 88,
                            "txn_id": "txn_4kQmXr9LpW",
                            "txn_amount": 150.00,
                            "payment_request_id": 101,
                            "merchant_id": 12,
                            "customer_id": 5,
                            "is_scheduled": False,
                            "is_retry": False
                        }
                    },
                    "response_status": 200,
                    "response_body": "OK",
                    "attempt_count": 1,
                    "delivered_at": "2026-04-08T13:46:02Z",
                    "failed_at": None,
                    "created_at": "2026-04-08T13:46:00Z"
                },
                {
                    "id": 54,
                    "event_type": "transaction.failed",
                    "event_id": "evt_b2c3d4e5f6a1",
                    "payload": {
                        "event_type": "transaction.failed",
                        "event_id": "evt_b2c3d4e5f6a1",
                        "timestamp": 1712344500,
                        "merchant_id": 12,
                        "data": {
                            "transaction_id": 87,
                            "txn_amount": 200.00,
                            "payment_request_id": 100,
                            "merchant_id": 12,
                            "customer_id": 5,
                            "retry_token": None,
                            "retry_link": None,
                            "is_merchant_triggered": False
                        }
                    },
                    "response_status": 500,
                    "response_body": "Internal Server Error",
                    "attempt_count": 3,
                    "delivered_at": None,
                    "failed_at": "2026-04-08T13:20:00Z",
                    "created_at": "2026-04-08T13:15:00Z"
                }
            ],
            "total": 2,
            "page": 1,
            "page_size": 20
        }
    },
    ("GET", "/api/v1/developer/logs"): {
        "response": {
            "items": [
                {
                    "id": 201,
                    "key_id": "key_a1b2c3d4",
                    "method": "GET",
                    "path": "/api/v1/transactions",
                    "status_code": 200,
                    "duration_ms": 43,
                    "ip_address": "203.0.113.42",
                    "request_id": "req_9xKpLm3Nq",
                    "error_message": None,
                    "created_at": "2026-04-08T13:45:00Z"
                }
            ],
            "total": 1,
            "page": 1,
            "page_size": 50
        }
    },
    # ── Customer — Create ────────────────────────────────────────────────────
    ("POST", "/api/v1/customers"): {
        "request": {
            "first_name": "Jane",
            "last_name": "Smith",
            "email": "jane.smith@example.com",
            "phone": "+15550100",
            "office_phone": "+15550200",
            "account_type": "business",
            "business_legal_name": "Smith Enterprises LLC",
            "is_active": True
        },
        "response": {
            "data": {
                "id": 5,
                "account_literal": "ACCDRE954419A09",
                "first_name": "Jane",
                "last_name": "Smith",
                "email": "jane.smith@example.com",
                "phone": "+15550100",
                "account_type": "business",
                "business_legal_name": "Smith Enterprises LLC",
                "is_active": True,
                "created_at": "2026-04-08T12:00:00Z"
            },
            "success": True,
            "message": "Customer created successfully"
        }
    },
    # ── Customer Contacts ────────────────────────────────────────────────────
    ("GET", "/api/v1/customers/{customer_id}/contacts"): {
        "response": {
            "data": [
                {
                    "id": 12,
                    "first_name": "Bob",
                    "last_name": "Jones",
                    "email": "bob.jones@smithenterprises.com",
                    "phone": "+15550199",
                    "office_phone": "+15550200",
                    "title": "CFO",
                    "relation": "billing",
                    "is_active": True,
                    "created_at": "2026-04-08T12:05:00Z"
                }
            ],
            "success": True,
            "message": "Found 1 contacts",
            "meta": {
                "total": 1,
                "page": 1,
                "per_page": 10
            }
        }
    },
    ("POST", "/api/v1/customers/{customer_id}/contacts"): {
        "request": {
            "first_name": "Bob",
            "last_name": "Jones",
            "email": "bob.jones@smithenterprises.com",
            "phone": "+15550199",
            "office_phone": "+15550200",
            "title": "CFO",
            "relation": "billing"
        },
        "response": {
            "data": {
                "id": 12,
                "first_name": "Bob",
                "last_name": "Jones",
                "email": "bob.jones@smithenterprises.com",
                "phone": "+15550199",
                "office_phone": "+15550200",
                "title": "CFO",
                "relation": "billing",
                "is_active": True,
                "created_at": "2026-04-08T12:05:00Z"
            },
            "success": True,
            "message": "Contact created successfully"
        }
    },
    ("PUT", "/api/v1/customers/{customer_id}/contacts/{contact_id}"): {
        "request": {
            "title": "Director of Finance",
            "office_phone": "+15550300"
        },
        "response": {
            "data": {
                "id": 12,
                "first_name": "Bob",
                "last_name": "Jones",
                "email": "bob.jones@smithenterprises.com",
                "phone": "+15550199",
                "office_phone": "+15550300",
                "title": "Director of Finance",
                "relation": "billing",
                "is_active": True,
                "created_at": "2026-04-08T12:05:00Z",
                "updated_at": "2026-04-09T09:00:00Z"
            },
            "success": True,
            "message": "Contact updated successfully"
        }
    },
    # ── Invoice ──────────────────────────────────────────────────────────────
    ("GET", "/api/v1/invoices/{invoice_literal}"): {
        "response": {
            "data": {
                "id": 77,
                "invoice_literal": "INV-0042",
                "invoice_number": 42,
                "status": "paid",
                "amount": 150.00,
                "currency": "USD",
                "due_date": None,
                "description": "Web Design Services — April 2026",
                "reference": "INV-2026-042",
                "customer": {
                    "id": 5,
                    "customer_id": "ACCDRE954419A09",
                    "first_name": "Jane",
                    "last_name": "Smith",
                    "email": "jane.smith@example.com"
                },
                "line_items": [
                    {
                        "description": "Web Design (8 hrs)",
                        "quantity": 8,
                        "unit_price": 18.75,
                        "total": 150.00
                    }
                ],
                "transactions": [
                    {
                        "id": 88,
                        "txn_id": "txn_4kQmXr9LpW",
                        "txn_amount": 150.00,
                        "txn_status": "approved",
                        "occurred_at": "2026-04-08T13:45:00Z"
                    }
                ],
                "created_at": "2026-04-08T13:45:30Z",
                "updated_at": "2026-04-08T13:45:30Z"
            },
            "success": True,
            "message": None
        }
    },
}


@portal_router.get("/developer-portal/openapi.json", include_in_schema=False)
async def developer_portal_openapi(request: Request):
    """Return filtered OpenAPI spec for developer-accessible endpoints only."""
    from fastapi.openapi.utils import get_openapi
    import re

    # Use request.app to access the FastAPI application instance.
    # This avoids a circular import that would arise from `from src.main import app`.
    main_app = request.app

    full_spec = get_openapi(
        title="HubWallet API",
        version="1.0.0",
        description=CORE_CONCEPTS_MARKDOWN,
        routes=main_app.routes,
    )

    # Filter to developer-accessible paths only
    filtered_paths = {}
    for path, path_item in full_spec.get("paths", {}).items():
        # Normalize path for comparison
        normalized = re.sub(r"\{[^}]+\}", "{id}", path)
        if path in DEVELOPER_PATHS or any(
            re.sub(r"\{[^}]+\}", "{id}", dp) == normalized
            for dp in DEVELOPER_PATHS
        ):
            filtered_paths[path] = path_item

    # Friendly tag name mapping — normalises FastAPI auto-generated uppercase tags
    TAG_RENAME = {
        "PRODUCTS": "Products",
        "CUSTOMERS": "Customers",
        "CUSTOMER CONTACTS": "Customers",       # contacts grouped under Customers tag
        "CUSTOMER ADDRESSES": "Customers",
        "PAYMENT_REQUESTS": "Payment Requests",
        "TRANSACTIONS": "Transactions",
        "INVOICES": "Invoices",
        "Developer Portal": "Developer",
        "Developer": "Developer",
    }

    # Override per-operation security and normalise tags.
    # Developer management endpoints (/developer/*) require a merchant JWT;
    # all other endpoints (transactions, customers, etc.) use an API key.
    api_key_security = [{"ApiKeyHeader": []}, {"BearerApiKey": []}]
    jwt_security = [{"MerchantJWT": []}]
    for path, path_item in filtered_paths.items():
        is_mgmt = path.startswith("/api/v1/developer/")
        for method_obj in path_item.values():
            if isinstance(method_obj, dict):
                method_obj["security"] = jwt_security if is_mgmt else api_key_security
                if "tags" in method_obj:
                    # Rename then deduplicate — tags can appear twice when both
                    # the router definition and include_router() set the same tag
                    renamed = [TAG_RENAME.get(t, t) for t in method_obj["tags"]]
                    method_obj["tags"] = list(dict.fromkeys(renamed))

    # Inject operation docs (summary + description) from _OPERATION_DOCS
    for path, path_item in filtered_paths.items():
        for method, method_obj in path_item.items():
            if not isinstance(method_obj, dict):
                continue
            docs = _OPERATION_DOCS.get((method.upper(), path))
            if docs:
                if "summary" in docs:
                    method_obj["summary"] = docs["summary"]
                if "description" in docs:
                    method_obj["description"] = docs["description"]

    # Inject request/response examples from _EXAMPLES
    for path, path_item in filtered_paths.items():
        for method, method_obj in path_item.items():
            if not isinstance(method_obj, dict):
                continue
            ex = _EXAMPLES.get((method.upper(), path))
            if not ex:
                continue
            # Response example — attach to first 2xx response found
            if "response" in ex and "responses" in method_obj:
                for code in ("200", "201"):
                    if code in method_obj["responses"]:
                        (
                            method_obj["responses"][code]
                            .setdefault("content", {})
                            .setdefault("application/json", {})
                        )["example"] = ex["response"]
                        break
            # Request body example
            if "request" in ex and "requestBody" in method_obj:
                (
                    method_obj["requestBody"]
                    .setdefault("content", {})
                    .setdefault("application/json", {})
                )["example"] = ex["request"]

    full_spec["paths"] = filtered_paths

    # Top-level tags block — controls sidebar order and display names
    full_spec["tags"] = [
        {"name": "Products", "description": "Browse and look up product catalogue items."},
        {"name": "Customers", "description": "Search, create, and manage customer records and their contacts."},
        {"name": "Payment Requests", "description": "Initiate and manage payment requests. Entry point for the Virtual Terminal flow."},
        {"name": "Transactions", "description": "Query transaction history, issue refunds, and download receipt PDFs."},
        {"name": "Invoices", "description": "Retrieve auto-generated invoices and download invoice PDFs."},
        {"name": "Developer", "description": "Manage API keys, webhooks, and view request logs."},
    ]

    # Add API key security schemes
    if "components" not in full_spec:
        full_spec["components"] = {}
    full_spec["components"]["securitySchemes"] = {
        "ApiKeyHeader": {
            "type": "apiKey",
            "in": "header",
            "name": "X-API-Key",
            "description": "**API key** (for Transactions, Customers, Products, Payment Requests).\nGenerate one from your merchant portal under Settings → Developer → API Keys.",
        },
        "BearerApiKey": {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "API Key",
            "description": "**API key as Bearer token** — alternative to X-API-Key header.\nValue: `sk_live_xxx` or `sk_test_xxx`",
        },
        "MerchantJWT": {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "JWT",
            "description": "**Merchant portal JWT** (for Developer management endpoints: API Keys, Webhooks, Logs).\nObtain by logging into the merchant portal and copying the access token from `POST /auth/login`.",
        },
    }
    # Remove auto-generated HTTPBearer from components so it doesn't appear
    if "securitySchemes" in full_spec.get("components", {}):
        full_spec["components"]["securitySchemes"].pop("HTTPBearer", None)

    full_spec["security"] = api_key_security

    return JSONResponse(content=full_spec)


@portal_router.get("/developer-portal/openapi-try.json", include_in_schema=False)
async def developer_portal_openapi_try(request: Request):
    """Filtered OpenAPI spec for Swagger UI — uses a short description so endpoints render first."""
    full = (await developer_portal_openapi(request)).body
    import json as _json
    spec = _json.loads(full)
    # Replace the long Core Concepts markdown with a one-liner so endpoints are
    # the first thing visible in the Swagger UI interactive playground.
    spec["info"]["description"] = (
        "Interactive playground for the HubWallet API. "
        "Authenticate with your API key using the **Authorize** button above. "
        "Full documentation and guides are available at "
        "[/developer-portal](/developer-portal)."
    )
    return JSONResponse(content=spec)
