"""WorkOS sign-in for the hosted playground.

Every request the app serves passes the login gate. A request carrying a live
session cookie for the configured organization goes through; otherwise the
gate redirects a browser to ``/login`` or refuses an API call with 401. A few
paths are open without a session: the sign-in routes themselves, the health
route the load balancer and the startup page poll, and the static assets,
which are the app's code and carry no document data.

Four routes come with the gate. ``/login`` sends the browser to WorkOS,
``/callback`` finishes the sign-in WorkOS sends back and sets the cookie,
``/logout`` clears the cookie and ends the WorkOS session, and ``/api/me``
tells the client who is signed in so it can offer sign-out. The sign-in
itself, the organization check and the cookie all come from
``quber.playground.session``, which the wake function shares: a person who
signs in while no task is running gets the same cookie from the function,
and the gate here accepts it once the task is up.

The gate is a pure ASGI middleware rather than a Starlette
``BaseHTTPMiddleware`` so the answer and batch streams pass through untouched.
It checks the organization on every request, not only at sign-in, so a
cookie issued under another organization, or before the setting changed, is
refused like an expired one.

``install`` decides whether the sign-in is on. All four settings unset means
the developer host, where the app runs open. All four set means the hosted
playground. A partial set is a misconfiguration and is refused at startup,
because the alternative is a hosted app that silently runs open.

The gate is also where signed-in use is counted. Every request that passes
with a valid session is one unit of activity, and ``ActivityMeter`` reports
the count to CloudWatch once a minute when a namespace is configured. The
hosted service's idle alarm watches that metric rather than the load
balancer's request count, because a public hostname is never quiet: internet
scanners reach the sign-in page every few minutes, and only a session tells a
person apart from them.
"""

from __future__ import annotations

import threading
import time
from typing import Optional
from urllib.parse import urlencode

from fastapi import FastAPI, Request
from loguru import logger
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send

from quber.playground import session
from quber.settings import PlaygroundSettings

OPEN_PATHS = frozenset({"/login", "/callback", "/logout", "/healthz"})
OPEN_PREFIXES = ("/static/",)
ACTIVITY_METRIC = "AuthenticatedRequests"
ACTIVITY_INTERVAL_SECONDS = 60
# The app serves the mark from its static files; the wake function inlines it.
MARK_SRC = "/static/qubera-mark-160.png"


class ActivityMeter:
    """Counts requests that carried a valid session and reports the count to
    CloudWatch once a minute. A minute with nothing to report is reported as
    zero, so the metric has a datapoint whenever a task is up; the alarm's
    missing-data rule covers the time nothing is up at all."""

    def __init__(self, namespace: str, service: str) -> None:
        self.namespace = namespace
        self.service = service
        self.count = 0
        self.lock = threading.Lock()
        threading.Thread(target=self.run, name="activity-meter", daemon=True).start()

    def record(self) -> None:
        with self.lock:
            self.count += 1

    def run(self) -> None:
        import boto3

        client = boto3.client("cloudwatch")
        while True:
            time.sleep(ACTIVITY_INTERVAL_SECONDS)
            with self.lock:
                count, self.count = self.count, 0
            try:
                client.put_metric_data(
                    Namespace=self.namespace,
                    MetricData=[
                        {
                            "MetricName": ACTIVITY_METRIC,
                            "Dimensions": [{"Name": "Service", "Value": self.service}],
                            "Value": count,
                            "Unit": "Count",
                        }
                    ],
                )
            except Exception as exc:
                logger.warning("activity metric not sent: {}", exc)


class LoginGate:
    def __init__(
        self,
        app: ASGIApp,
        *,
        secret: str,
        organization_id: str,
        meter: Optional[ActivityMeter] = None,
    ) -> None:
        self.app = app
        self.secret = secret
        self.organization_id = organization_id
        self.meter = meter

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        path = scope["path"]
        if path in OPEN_PATHS or path.startswith(OPEN_PREFIXES):
            await self.app(scope, receive, send)
            return
        request = Request(scope, receive)
        identity = session.verify(self.secret, self.organization_id, request.cookies.get(session.COOKIE))
        if identity is not None:
            if self.meter is not None:
                self.meter.record()
            scope.setdefault("state", {})["identity"] = identity
            await self.app(scope, receive, send)
            return
        if path.startswith("/api/"):
            response = JSONResponse({"detail": "login required"}, status_code=401)
        else:
            query = scope.get("query_string", b"").decode()
            target = path + ("?" + query if query else "")
            response = RedirectResponse("/login?" + urlencode({"next": target}), status_code=302)
        await response(scope, receive, send)


def request_base(request: Request) -> str:
    """The site the browser used. The load balancer terminates TLS, so the
    browser's scheme arrives in the forwarded header."""
    scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
    return session.base_url(scheme, request.headers.get("host") or request.url.netloc)


def secure(request: Request) -> bool:
    """Whether to mark the cookie Secure: it is then sent back only over HTTPS,
    which is every hosted request."""
    return request.headers.get("x-forwarded-proto") == "https"


def install(app: FastAPI, settings: PlaygroundSettings) -> bool:
    """Put the sign-in in front of ``app`` when settings ask for it.

    Returns whether the sign-in is on. Raises when the four settings are only
    partly set, so a hosted app never starts open by accident. ``/api/me`` is
    added either way, answering an empty object on the developer host.
    """
    values = (
        settings.workos_api_key,
        settings.workos_client_id,
        settings.workos_organization_id,
        settings.session_secret,
    )
    if not any(values):

        def me_open() -> dict[str, str]:
            return {}

        app.add_api_route("/api/me", me_open, methods=["GET"], include_in_schema=False)
        return False
    if not all(values):
        raise RuntimeError(
            "WORKOS_API_KEY, WORKOS_CLIENT_ID, WORKOS_ORGANIZATION_ID and PLAYGROUND_SESSION_SECRET "
            "must be set together; the sign-in is on only when all four are present."
        )
    api_key, client_id, organization_id, secret = (str(v) for v in values)
    config = session.WorkOS(
        api_key=api_key, client_id=client_id, organization_id=organization_id, session_secret=secret
    )
    meter = (
        ActivityMeter(settings.activity_namespace, "quber-playground")
        if settings.activity_namespace
        else None
    )

    def login(request: Request, next: Optional[str] = None) -> HTMLResponse:
        return HTMLResponse(session.login_page(config, request_base(request), next, MARK_SRC))

    # Plain functions, so the call to WorkOS runs on the thread pool and never
    # blocks the event loop.
    def callback(request: Request, code: Optional[str] = None, state: Optional[str] = None) -> Response:
        outcome = session.complete_sign_in(config, request_base(request), code, state, MARK_SRC)
        if outcome.token is None or outcome.location is None:
            logger.info("sign-in not accepted: status {}", outcome.status)
            return HTMLResponse(outcome.page or "", status_code=outcome.status)
        response = RedirectResponse(outcome.location, status_code=303)
        response.headers.append("set-cookie", session.cookie_header(outcome.token, secure=secure(request)))
        return response

    def logout(request: Request) -> RedirectResponse:
        identity = session.signed_identity(secret, request.cookies.get(session.COOKIE))
        base = request_base(request)
        target = session.logout_url(identity.sid, base) if identity and identity.sid else "/"
        response = RedirectResponse(target, status_code=302)
        response.headers.append("set-cookie", session.clear_cookie_header(secure=secure(request)))
        return response

    def me(request: Request) -> dict[str, str]:
        identity: Optional[session.Identity] = getattr(request.state, "identity", None)
        return {"email": identity.email} if identity is not None else {}

    app.add_api_route("/login", login, methods=["GET"], response_class=HTMLResponse, include_in_schema=False)
    app.add_api_route("/callback", callback, methods=["GET"], include_in_schema=False)
    app.add_api_route("/logout", logout, methods=["GET"], include_in_schema=False)
    app.add_api_route("/api/me", me, methods=["GET"], include_in_schema=False)
    app.add_middleware(LoginGate, secret=secret, organization_id=organization_id, meter=meter)
    return True
