"""Write-proxy middleware — forward non-GET requests to an upstream API.

The Console v2 host architecture (see console-v2/morning-read/host-arch-spec.md):
MacBook runs a local FastAPI for fast reads against the Dropbox-synced project
tree. Mutations have to go to Studio's API so a single canonical writer owns
the shot state files — symmetric writes risk silent Dropbox conflict files
(`shots/X (1).json`) that the API never serves.

When `RECOIL_WRITE_UPSTREAM` is set, this middleware intercepts every request
whose method is NOT GET/HEAD/OPTIONS and forwards it to that upstream URL,
preserving method, path, query string, body, and headers (minus hop-by-hop).
The upstream's response is streamed back to the caller. Local handlers are
never invoked for these requests.

When `RECOIL_WRITE_UPSTREAM` is unset (Studio's own process, or any node that
should accept writes locally), the middleware is a no-op pass-through.

This is opt-in via env var so the same image runs in both modes. Studio's
LaunchAgent doesn't set it; MacBook's `.env.local` does.
"""

from __future__ import annotations

import os
from typing import Iterable

import httpx
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse


# Hop-by-hop headers that should NOT be forwarded per RFC 7230 §6.1. httpx
# handles most of these internally, but we strip them from BOTH directions
# defensively to avoid surprise behavior.
_HOP_BY_HOP = frozenset(
    {
        "connection",
        "keep-alive",
        "proxy-authenticate",
        "proxy-authorization",
        "te",
        "trailers",
        "transfer-encoding",
        "upgrade",
        # FastAPI sets these on its own response; passing through the
        # upstream's would double them up.
        "content-encoding",
        "content-length",
    }
)


# Only forward methods that mutate state. GET/HEAD/OPTIONS always hit local
# handlers so reads stay fast.
_FORWARDED_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})


def _filter_headers(headers: Iterable[tuple[str, str]]) -> dict[str, str]:
    """Return a dict of headers safe to forward (hop-by-hop stripped)."""
    return {
        k: v for k, v in headers if k.lower() not in _HOP_BY_HOP
    }


class WriteProxyMiddleware(BaseHTTPMiddleware):
    """Forward mutation requests to an upstream API when configured."""

    def __init__(self, app, upstream: str | None = None):
        super().__init__(app)
        # Trim trailing slash so f"{upstream}{path}" composes cleanly.
        self.upstream = (upstream or "").rstrip("/") or None
        # Single shared client — connection pool reuse, configurable timeout.
        # 30s is generous for mutation requests; tighten later if needed.
        self._client = (
            httpx.AsyncClient(base_url=self.upstream, timeout=30.0)
            if self.upstream
            else None
        )

    async def dispatch(self, request: Request, call_next):
        # No upstream configured → pass through to local handlers as if this
        # middleware weren't installed.
        if self._client is None:
            return await call_next(request)

        # Read-side methods always hit local handlers (this is the whole
        # reason for the architecture).
        if request.method.upper() not in _FORWARDED_METHODS:
            return await call_next(request)

        # Reassemble the upstream URL. The httpx client's base_url handles
        # the host part; we just need path + query.
        url = request.url.path
        if request.url.query:
            url = f"{url}?{request.url.query}"

        # Stream the request body forward. For small JSON payloads this is
        # the same as awaiting body(), but it scales to larger uploads (file
        # drops from the pasteboard, etc.) without buffering everything in
        # memory.
        body = await request.body()

        try:
            upstream_response = await self._client.request(
                method=request.method,
                url=url,
                content=body,
                headers=_filter_headers(request.headers.items()),
            )
        except httpx.RequestError as exc:
            # Upstream unreachable, timed out, etc. Surface as 502 so the
            # frontend can distinguish from a real 4xx/5xx the upstream
            # actually generated.
            return Response(
                content=(
                    f'{{"detail": "write upstream unreachable: '
                    f'{type(exc).__name__}: {exc}"}}'
                ),
                status_code=502,
                media_type="application/json",
            )

        # Strip hop-by-hop on the way back. Content-Length will be regenerated
        # by Starlette as it serializes the response.
        response_headers = _filter_headers(upstream_response.headers.items())

        return Response(
            content=upstream_response.content,
            status_code=upstream_response.status_code,
            headers=response_headers,
            media_type=upstream_response.headers.get("content-type"),
        )


def install_write_proxy(app) -> None:
    """Install the WriteProxyMiddleware if `RECOIL_WRITE_UPSTREAM` is set.

    Called from main.py during app construction. Idempotent — calling twice
    is a configuration error caught by FastAPI's middleware stack rejecting
    duplicates.
    """
    upstream = os.environ.get("RECOIL_WRITE_UPSTREAM", "").strip()
    if upstream:
        app.add_middleware(WriteProxyMiddleware, upstream=upstream)


__all__ = ["WriteProxyMiddleware", "install_write_proxy"]
