# Debrid Provider Client Pattern

## Overview

When building a streaming app backend that supports multiple debrid providers (Real-Debrid, AllDebrid, Premiumize, TorBox), the provider clients follow a strict Clean Architecture pattern:

## Architecture

```
application/interfaces.py  →  DebridProviderClient (Protocol port)
infrastructure/provider_clients.py  →  BaseDebridClient + concrete clients
presentation/dependencies.py  →  PROVIDER_CLIENTS registry
```

## Protocol Port

```python
class DebridProviderClient(Protocol):
    provider: str  # e.g. "real_debrid", "torbox"

    async def validate_credentials(self, api_key: str) -> AccountStatus:
        ...
```

The `provider` attribute is the key used in the registry dict.

## Base Client

```python
class BaseDebridClient(DebridProviderClient, ABC):
    provider: str = "base"

    def __init__(self, http_client: httpx.AsyncClient | None = None):
        self._client = http_client or httpx.AsyncClient(timeout=15.0)

    async def close(self) -> None:
        await self._client.aclose()
```

All concrete clients inherit from this. The `_client` attribute is replaced in tests with a mocked `httpx.AsyncClient`.

## Concrete Client Example (Real-Debrid)

```python
class RealDebridClient(BaseDebridClient):
    provider = "real_debrid"
    BASE_URL = "https://api.real-debrid.com/rest/1.0"

    async def validate_credentials(self, api_key: str) -> AccountStatus:
        resp = await self._client.get(
            f"{self.BASE_URL}/user",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        resp.raise_for_status()
        data = resp.json()
        return AccountStatus(
            provider=self.provider,
            user_id=str(data.get("id", "")),
            username=data.get("username"),
            is_active=data.get("type", "") == "Premium",
            ...
        )
```

## Registry

```python
PROVIDER_CLIENTS: dict[str, BaseDebridClient] = {
    "real_debrid": RealDebridClient(),
    "all_debrid": AllDebridClient(),
    "premiumize": PremiumizeClient(),
    "torbox": TorBoxClient(),
}
```

## Use Case Injection

```python
from collections.abc import Mapping

class DebridUseCases:
    def __init__(
        self,
        account_repo: DebridAccountRepository,
        provider_clients: Mapping[str, DebridProviderClient],
    ):
        ...
```

Uses `Mapping` (covariant) instead of `dict` (invariant) to satisfy mypy.

## API Key Security

**API keys must NEVER be stored as plaintext.** Use symmetric encryption with `cryptography.fernet`:

```python
from app.core.security import encrypt_api_key, decrypt_api_key

# In use case — encrypt before storing
api_key_encrypted = encrypt_api_key(credentials.api_key)
account = await repo.create(api_key_encrypted=api_key_encrypted, ...)

# On validate — decrypt for provider API call
api_key = await repo.get_api_key(account.id)
status = await client.validate_credentials(api_key=api_key)
```

The `DebridAccountRepository` port should have a `get_api_key(account_id) -> str | None` method that handles decryption. The domain entity never holds the key — encrypted or otherwise.

## App Lifespan Management

Provider clients hold persistent HTTP connections. Register a shutdown hook in the app lifespan:

```python
# app/modules/<name>/presentation/dependencies.py
async def close_provider_clients() -> None:
    for client in PROVIDER_CLIENTS.values():
        await client.close()

# app/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await close_provider_clients()
```

## Testing

**Do NOT mutate the PROVIDER_CLIENTS dict.** Instead, swap the `_client` attribute on each instance:

```python
from app.modules.debrid.presentation.dependencies import PROVIDER_CLIENTS

mock_transport = httpx.MockTransport(combined_handler)
original_transports = {}
for name, client in PROVIDER_CLIENTS.items():
    original_transports[name] = client._client
    client._client = httpx.AsyncClient(transport=mock_transport, timeout=15.0)

# ... run tests ...

# Restore original transports
for name, orig in original_transports.items():
    if name in PROVIDER_CLIENTS:
        PROVIDER_CLIENTS[name]._client = orig
```

**Why this is safer:** If a test fails before `yield`, the registry dict is intact — `PROVIDER_CLIENTS` still references the real client objects. Only the `_client` attribute was swapped, and it gets cleaned up by engine disposal. The old approach of `dict(PROVIDER_CLIENTS)` + `.clear()` + `.update()` corrupted the registry on test failure.

### Mock handler pattern

```python
HANDLERS: dict[tuple[str, str], object] = {
    ("GET", "api.real-debrid.com"): _real_debrid_handler,
    ("GET", "torbox.app"): _torbox_handler,
    ("GET", "api.alldebrid.com"): _all_debrid_handler,
    ("POST", "api.premiumize.me"): _premiumize_handler,
}

def _combined_mock_handler(request: httpx.Request) -> httpx.Response:
    handler = HANDLERS.get((request.method, request.url.host))
    if handler:
        return handler(request)
    return httpx.Response(404, json={"error": "Not found"})
```

Use `(method, host)` tuples as keys instead of `if/elif` chains — faster and cleaner.

### Mock for form-encoded providers (Premiumize)

```python
def _premiumize_handler(request: httpx.Request) -> httpx.Response:
    import urllib.parse
    body = request.read()
    params = urllib.parse.parse_qs(body.decode()) if body else {}
    api_key = params.get("apikey", [None])[0]
    if not api_key:
        return httpx.Response(200, json={"status": "fail"})
    return httpx.Response(200, json=_PREMIUMIZE_ACCOUNT)
```

**Important:** `dict(request.read().decode())` does NOT parse form data — it iterates characters. Always use `urllib.parse.parse_qs` for form-encoded POST bodies.

## Provider API Quick Reference

| Provider | Auth | Endpoint | Notes |
|----------|------|----------|-------|
| Real-Debrid | Bearer token | `GET /rest/1.0/user` | Standard OAuth2 Bearer |
| AllDebrid | Query param | `GET /v5/user/info?apikey=...` | API key as query param |
| Premiumize | POST body | `POST /api/v1/account` | API key in form data |
| TorBox | Query param | `GET /api/account_info?apikey=...` | Returns `{"success": true, "data": {...}}` |
