from __future__ import annotations

import json
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse

from .models import LeaseRequest, LeaseView
from .settings import ManagerSettings
from .store import IdempotencyConflict, LeaseCapacityExceeded, LeaseStore


class LaunchHookError(RuntimeError):
    pass


class ManagerRuntime:
    def __init__(
        self,
        settings: ManagerSettings,
        *,
        client: httpx.AsyncClient | None = None,
    ) -> None:
        self.settings = settings
        self.store = LeaseStore(
            endpoint=settings.media_url,
            max_active=settings.max_active_leases,
            max_records=settings.max_records,
            ttl_s=settings.lease_ttl_s,
        )
        self._owns_client = client is None
        self.client = client or httpx.AsyncClient(
            follow_redirects=False,
            timeout=httpx.Timeout(
                settings.request_timeout_s,
                connect=settings.connect_timeout_s,
            ),
        )

    async def close(self) -> None:
        if self._owns_client:
            await self.client.aclose()

    async def hook(self, url: str, capability: str, lease_id: str, action: str) -> None:
        body = {"action": action, "capability": capability, "lease_id": lease_id}
        try:
            async with self.client.stream(
                "POST",
                url,
                headers={"Idempotency-Key": f"lease-{action}:{lease_id}"},
                json=body,
            ) as response:
                if not 200 <= response.status_code < 300:
                    raise LaunchHookError(f"hook returned HTTP {response.status_code}")
                size = 0
                async for chunk in response.aiter_bytes():
                    size += len(chunk)
                    if size > self.settings.max_response_bytes:
                        raise LaunchHookError("hook response exceeded its configured limit")
        except LaunchHookError:
            raise
        except httpx.HTTPError as exc:
            raise LaunchHookError("hook endpoint was unavailable") from exc


def _idempotency_key(value: str | None) -> str:
    normalized = (value or "").strip()
    if not normalized or len(normalized) > 240:
        raise HTTPException(status_code=400, detail="A bounded Idempotency-Key is required")
    return normalized


def create_app(
    settings: ManagerSettings | None = None,
    *,
    client: httpx.AsyncClient | None = None,
) -> FastAPI:
    settings = (settings or ManagerSettings.from_env()).validated()
    runtime = ManagerRuntime(settings, client=client)

    @asynccontextmanager
    async def lifespan(_: FastAPI):
        yield
        await runtime.close()

    app = FastAPI(
        title="OMNI local media lease manager",
        version="1.0",
        docs_url=None,
        redoc_url=None,
        openapi_url=None,
        lifespan=lifespan,
    )
    app.state.runtime = runtime

    @app.middleware("http")
    async def bound_requests(request: Request, call_next):
        if request.headers.get("transfer-encoding"):
            return JSONResponse(status_code=411, content={"detail": "Chunked requests are denied"})
        raw_length = request.headers.get("content-length")
        if raw_length:
            try:
                length = int(raw_length)
            except ValueError:
                return JSONResponse(status_code=400, content={"detail": "Invalid Content-Length"})
            if length < 0 or length > settings.max_request_bytes:
                return JSONResponse(status_code=413, content={"detail": "Request body is too large"})
        return await call_next(request)

    @app.get("/health")
    @app.get("/healthz")
    async def health() -> dict[str, object]:
        return {
            "status": "ready",
            "service": "media-manager",
            "launch_enabled": settings.launch_enabled,
            "model_downloads": "disabled",
            "active_leases": await runtime.store.active_count(),
        }

    @app.post("/v1/leases", response_model=LeaseView)
    async def acquire_lease(
        request: LeaseRequest,
        idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
    ) -> Response:
        key = _idempotency_key(idempotency_key)
        if settings.launch_enabled and not settings.launch_url(request.capability):
            raise HTTPException(
                status_code=503,
                detail=f"No explicit local launch hook is configured for {request.capability}",
            )
        try:
            lease, created = await runtime.store.acquire(request.capability, key)
        except IdempotencyConflict as exc:
            raise HTTPException(status_code=409, detail=str(exc)) from None
        except LeaseCapacityExceeded as exc:
            raise HTTPException(status_code=503, detail=str(exc)) from None
        if created and settings.launch_enabled:
            try:
                await runtime.hook(
                    settings.launch_url(request.capability) or "",
                    request.capability,
                    lease.lease_id,
                    "launch",
                )
            except LaunchHookError as exc:
                await runtime.store.discard(lease.lease_id)
                raise HTTPException(status_code=503, detail=str(exc)) from None
        return JSONResponse(
            status_code=201 if created else 200,
            content=json.loads(lease.model_dump_json()),
        )

    @app.get("/v1/leases/{lease_id}", response_model=LeaseView)
    async def get_lease(lease_id: str) -> LeaseView:
        if len(lease_id) > 160:
            raise HTTPException(status_code=404, detail="Lease not found")
        lease = await runtime.store.get(lease_id)
        if lease is None:
            raise HTTPException(status_code=404, detail="Lease not found")
        return lease

    @app.delete("/v1/leases/{lease_id}", status_code=204)
    async def release_lease(lease_id: str) -> Response:
        lease, _changed = await runtime.store.release(lease_id)
        if lease is None:
            raise HTTPException(status_code=404, detail="Lease not found")
        if settings.launch_enabled:
            hook = settings.release_url(lease.capability)
            if hook and await runtime.store.active_count(lease.capability) == 0:
                try:
                    await runtime.hook(hook, lease.capability, lease.lease_id, "release")
                except LaunchHookError as exc:
                    raise HTTPException(status_code=502, detail=str(exc)) from None
        return Response(status_code=204)

    return app
