//! SSE realtime endpoint — `GET /realtime/topics/{topic}` (v1.1.6).
//!
//! This is a data-plane surface, deliberately NOT under `/api/`
//! (realtime is its own versioning surface per the path scheme). It is
//! merged at the router root by the `picloud` binary alongside
//! `/healthz`, `/version`, and the user-route fallback.
//!
//! Handshake:
//!   1. Resolve `Host` → `app_id` (two-phase dispatch). No app → 404.
//!   2. Extract the token from `Authorization: Bearer <t>` OR `?token=<t>`
//!      (EventSource can't set custom headers, so the query form is the
//!      browser-compatible path).
//!   3. Ask the injected [`RealtimeAuthority`]: missing/internal topic →
//!      404, bad/absent token on a token-gated topic → 401, otherwise OK.
//!   4. Acquire a `broadcast::Receiver` and stream events as SSE until
//!      the client disconnects (dropping the receiver — the broadcaster
//!      cleans up on its own).
//!
//! Heartbeats (`:` comment lines) keep idle proxies from closing the
//! connection; interval is `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30).

use std::sync::Arc;
use std::time::Duration;

use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use picloud_shared::{RealtimeAuthority, RealtimeBroadcaster, SubscribeDenied};
use serde::Deserialize;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};

use crate::routing::AppDomainTable;

/// Default heartbeat interval (seconds) for idle SSE connections.
pub const DEFAULT_HEARTBEAT_SECS: u64 = 30;
const ENV_HEARTBEAT: &str = "PICLOUD_REALTIME_HEARTBEAT_SEC";

#[derive(Clone)]
pub struct RealtimeState {
    /// Host → app_id resolver (shared with the rest of the data plane).
    pub app_domains: Arc<AppDomainTable>,
    pub broadcaster: Arc<dyn RealtimeBroadcaster>,
    pub authority: Arc<dyn RealtimeAuthority>,
    pub heartbeat: Duration,
}

impl RealtimeState {
    #[must_use]
    pub fn new(
        app_domains: Arc<AppDomainTable>,
        broadcaster: Arc<dyn RealtimeBroadcaster>,
        authority: Arc<dyn RealtimeAuthority>,
    ) -> Self {
        Self {
            app_domains,
            broadcaster,
            authority,
            heartbeat: Duration::from_secs(heartbeat_secs_from_env()),
        }
    }
}

/// Read `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30, must be > 0).
#[must_use]
pub fn heartbeat_secs_from_env() -> u64 {
    match std::env::var(ENV_HEARTBEAT) {
        Err(_) => DEFAULT_HEARTBEAT_SECS,
        Ok(v) => match v.parse::<u64>() {
            Ok(n) if n > 0 => n,
            _ => {
                tracing::warn!(env = ENV_HEARTBEAT, value = %v, "invalid; using default");
                DEFAULT_HEARTBEAT_SECS
            }
        },
    }
}

/// Router for the realtime SSE surface. Merged at the router root.
pub fn realtime_router(state: RealtimeState) -> Router {
    Router::new()
        .route("/realtime/topics/{topic}", get(sse_topic))
        // §11.6 D2: group SHARED topics. Same Host→app dispatch; the owning group
        // is resolved from the app's chain by the authority (reads-open model).
        .route("/realtime/shared/topics/{topic}", get(sse_shared_topic))
        .with_state(state)
}

#[derive(Debug, Deserialize)]
struct TokenQuery {
    token: Option<String>,
}

async fn sse_topic(
    State(state): State<RealtimeState>,
    Path(topic): Path<String>,
    Query(q): Query<TokenQuery>,
    headers: HeaderMap,
) -> Response {
    // 1. Host → app.
    let host = headers
        .get("host")
        .and_then(|h| h.to_str().ok())
        .unwrap_or("");
    let Some(app_id) = state.app_domains.resolve_app(host) else {
        return not_found("no app claims this host");
    };

    // 2. Token: Authorization: Bearer <t> takes precedence, else ?token=.
    let token = bearer_token(&headers).or(q.token);

    // 3. Authorize.
    match state
        .authority
        .authorize_subscribe(app_id, &topic, token.as_deref())
        .await
    {
        Ok(()) => {}
        Err(SubscribeDenied::NotFound) => return not_found("topic not found"),
        Err(SubscribeDenied::Unauthorized) => return unauthorized(),
        Err(SubscribeDenied::Backend(e)) => {
            tracing::error!(error = %e, "realtime authority backend error");
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                axum::Json(serde_json::json!({ "error": "internal error" })),
            )
                .into_response();
        }
    }

    // 4. Subscribe + stream.
    let rx = match state.broadcaster.subscribe(app_id, &topic).await {
        Ok(rx) => rx,
        Err(e) => return subscribe_unavailable(&e),
    };

    let stream = event_stream(rx);
    let sse =
        Sse::new(stream).keep_alive(KeepAlive::new().interval(state.heartbeat).text("heartbeat"));

    // Sse sets Content-Type: text/event-stream + Cache-Control: no-cache.
    // Add X-Accel-Buffering: no so an intermediate nginx doesn't buffer
    // the stream (ignored by other proxies). Connection management is
    // hyper's concern (and is hop-by-hop on HTTP/1.1, server-managed on
    // HTTP/2), so we don't set Connection ourselves.
    let mut resp = sse.into_response();
    resp.headers_mut().insert(
        "X-Accel-Buffering",
        axum::http::HeaderValue::from_static("no"),
    );
    resp
}

/// §11.6 D2: SSE for a group SHARED topic. Host→app resolves the subscriber's
/// app; the authority resolves the owning group from that app's chain (reads
/// open — the declaration is the grant; a foreign-subtree app 404s). The stream
/// is keyed by the owning group, so every subtree subscriber shares one channel
/// and sees every subtree app's publishes.
async fn sse_shared_topic(
    State(state): State<RealtimeState>,
    Path(topic): Path<String>,
    headers: HeaderMap,
) -> Response {
    let host = headers
        .get("host")
        .and_then(|h| h.to_str().ok())
        .unwrap_or("");
    let Some(app_id) = state.app_domains.resolve_app(host) else {
        return not_found("no app claims this host");
    };

    let group_id = match state
        .authority
        .authorize_subscribe_shared(app_id, &topic)
        .await
    {
        Ok(gid) => gid,
        Err(SubscribeDenied::NotFound) => return not_found("shared topic not found"),
        Err(SubscribeDenied::Unauthorized) => return unauthorized(),
        Err(SubscribeDenied::Backend(e)) => {
            tracing::error!(error = %e, "realtime authority backend error");
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                axum::Json(serde_json::json!({ "error": "internal error" })),
            )
                .into_response();
        }
    };

    let rx = match state.broadcaster.subscribe_group(group_id, &topic).await {
        Ok(rx) => rx,
        Err(e) => return subscribe_unavailable(&e),
    };

    let sse = Sse::new(event_stream(rx))
        .keep_alive(KeepAlive::new().interval(state.heartbeat).text("heartbeat"));
    let mut resp = sse.into_response();
    resp.headers_mut().insert(
        "X-Accel-Buffering",
        axum::http::HeaderValue::from_static("no"),
    );
    resp
}

/// Map the broadcast receiver into a stream of SSE events. Lagged
/// notifications (slow consumer) are skipped; a closed channel
/// (`drop_topic`, or all senders gone) ends the stream and the SSE
/// connection closes cleanly.
fn event_stream(
    rx: tokio::sync::broadcast::Receiver<picloud_shared::RealtimeEvent>,
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
    BroadcastStream::new(rx).filter_map(|item| {
        let ev = item.ok()?; // drop Lagged errors
        let payload = serde_json::json!({
            "topic": ev.topic,
            "message": ev.message,
            "published_at": ev.published_at.to_rfc3339(),
        });
        Some(Ok(Event::default().data(payload.to_string())))
    })
}

fn bearer_token(headers: &HeaderMap) -> Option<String> {
    let raw = headers
        .get(axum::http::header::AUTHORIZATION)?
        .to_str()
        .ok()?;
    raw.strip_prefix("Bearer ")
        .map(|t| t.trim().to_string())
        .filter(|t| !t.is_empty())
}

fn not_found(msg: &str) -> Response {
    (
        StatusCode::NOT_FOUND,
        axum::Json(serde_json::json!({ "error": msg })),
    )
        .into_response()
}

fn unauthorized() -> Response {
    // Generic — never leaks which check failed.
    (
        StatusCode::UNAUTHORIZED,
        axum::Json(serde_json::json!({ "error": "unauthorized" })),
    )
        .into_response()
}

/// A `subscribe`/`subscribe_group` failure. `BroadcasterError` is transient by
/// construction (the channel cap is reached, or a rare poisoned lock / cluster
/// register failure), so it maps to 503 + `Retry-After: 1` — a "come back
/// later", not a 500. Aligns with the `PICLOUD_REALTIME_MAX_CHANNELS` doc.
fn subscribe_unavailable(e: &picloud_shared::BroadcasterError) -> Response {
    tracing::warn!(error = %e, "realtime subscription refused (broadcaster unavailable)");
    (
        StatusCode::SERVICE_UNAVAILABLE,
        [(axum::http::header::RETRY_AFTER, "1")],
        axum::Json(serde_json::json!({ "error": "realtime broadcaster unavailable" })),
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::realtime::InProcessBroadcaster;
    use crate::routing::AppDomainTable;
    use async_trait::async_trait;
    use axum::body::Body;
    use axum::http::Request;
    use picloud_shared::{AppId, RealtimeEvent};
    use tower::ServiceExt; // oneshot

    /// Authority stub returning a fixed verdict. For the shared-topic path it
    /// maps `Ok` → a fixed owning group, else the same denial.
    struct StubAuthority(Result<(), SubscribeDenied>, picloud_shared::GroupId);
    #[async_trait]
    impl RealtimeAuthority for StubAuthority {
        async fn authorize_subscribe(
            &self,
            _: AppId,
            _: &str,
            _: Option<&str>,
        ) -> Result<(), SubscribeDenied> {
            self.0.clone()
        }
        async fn authorize_subscribe_shared(
            &self,
            _: AppId,
            _: &str,
        ) -> Result<picloud_shared::GroupId, SubscribeDenied> {
            self.0.clone().map(|()| self.1)
        }
    }

    /// App-domain table that maps a fixed host to a fixed app.
    fn domains(host: &str, app: AppId) -> Arc<AppDomainTable> {
        use crate::routing::{parse_app_domain, CompiledAppDomain};
        let d = parse_app_domain(host).unwrap();
        let table = AppDomainTable::new();
        table.replace(vec![CompiledAppDomain {
            app_id: app,
            pattern: d.pattern,
            shape_key: d.shape_key,
        }]);
        Arc::new(table)
    }

    fn state(
        app: AppId,
        host: &str,
        verdict: Result<(), SubscribeDenied>,
        broadcaster: Arc<dyn RealtimeBroadcaster>,
    ) -> RealtimeState {
        RealtimeState {
            app_domains: domains(host, app),
            broadcaster,
            authority: Arc::new(StubAuthority(verdict, picloud_shared::GroupId::new())),
            heartbeat: Duration::from_millis(100),
        }
    }

    async fn get_status(state: RealtimeState, host: &str, topic: &str) -> StatusCode {
        let app = realtime_router(state);
        let req = Request::builder()
            .uri(format!("/realtime/topics/{topic}"))
            .header("host", host)
            .body(Body::empty())
            .unwrap();
        app.oneshot(req).await.unwrap().status()
    }

    #[tokio::test]
    async fn unknown_host_is_404() {
        let app = AppId::new();
        let st = state(
            app,
            "app.example.com",
            Ok(()),
            Arc::new(InProcessBroadcaster::new(8)),
        );
        // Request a different host → no app claims it.
        assert_eq!(
            get_status(st, "other.example.com", "chat").await,
            StatusCode::NOT_FOUND
        );
    }

    #[tokio::test]
    async fn not_found_topic_is_404() {
        let app = AppId::new();
        let st = state(
            app,
            "app.example.com",
            Err(SubscribeDenied::NotFound),
            Arc::new(InProcessBroadcaster::new(8)),
        );
        assert_eq!(
            get_status(st, "app.example.com", "ghost").await,
            StatusCode::NOT_FOUND
        );
    }

    #[tokio::test]
    async fn unauthorized_token_is_401() {
        let app = AppId::new();
        let st = state(
            app,
            "app.example.com",
            Err(SubscribeDenied::Unauthorized),
            Arc::new(InProcessBroadcaster::new(8)),
        );
        assert_eq!(
            get_status(st, "app.example.com", "chat").await,
            StatusCode::UNAUTHORIZED
        );
    }

    #[tokio::test]
    async fn public_topic_returns_event_stream() {
        let app = AppId::new();
        let st = state(
            app,
            "app.example.com",
            Ok(()),
            Arc::new(InProcessBroadcaster::new(8)),
        );
        let appr = realtime_router(st);
        let req = Request::builder()
            .uri("/realtime/topics/chat")
            .header("host", "app.example.com")
            .body(Body::empty())
            .unwrap();
        let resp = appr.oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        let ct = resp
            .headers()
            .get(axum::http::header::CONTENT_TYPE)
            .unwrap()
            .to_str()
            .unwrap();
        assert!(ct.starts_with("text/event-stream"));
        assert_eq!(resp.headers().get("x-accel-buffering").unwrap(), "no");
    }

    #[tokio::test]
    async fn subscribe_receives_published_event() {
        let app = AppId::new();
        let broadcaster = Arc::new(InProcessBroadcaster::new(8));
        let st = state(app, "app.example.com", Ok(()), broadcaster.clone());
        let appr = realtime_router(st);
        let req = Request::builder()
            .uri("/realtime/topics/chat")
            .header("host", "app.example.com")
            .body(Body::empty())
            .unwrap();
        let resp = appr.oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);

        // The handler has subscribed; publish and read the first chunk.
        // Give the streaming task a beat to register its receiver.
        let mut body = resp.into_body().into_data_stream();
        tokio::time::sleep(Duration::from_millis(50)).await;
        broadcaster
            .publish(
                app,
                "chat",
                RealtimeEvent {
                    topic: "chat".into(),
                    message: serde_json::json!({ "hi": 1 }),
                    published_at: chrono::Utc::now(),
                },
            )
            .await;

        let chunk = tokio::time::timeout(Duration::from_secs(2), body.next())
            .await
            .expect("a chunk within timeout")
            .expect("stream item")
            .expect("chunk ok");
        let text = String::from_utf8_lossy(&chunk);
        assert!(text.contains("data:"), "got: {text}");
        assert!(text.contains("\"hi\":1"), "got: {text}");
    }

    #[tokio::test]
    async fn shared_topic_not_resolved_is_404() {
        // §11.6 D2: a subscriber app whose chain reaches no declaring group gets
        // 404 (the reads-open resolution IS the authorization; a foreign subtree
        // never resolves).
        let app = AppId::new();
        let st = state(
            app,
            "app.example.com",
            Err(SubscribeDenied::NotFound),
            Arc::new(InProcessBroadcaster::new(8)),
        );
        let appr = realtime_router(st);
        let req = Request::builder()
            .uri("/realtime/shared/topics/events")
            .header("host", "app.example.com")
            .body(Body::empty())
            .unwrap();
        assert_eq!(
            appr.oneshot(req).await.unwrap().status(),
            StatusCode::NOT_FOUND
        );
    }

    #[tokio::test]
    async fn shared_topic_streams_group_publishes() {
        // A resolvable shared topic returns an SSE stream subscribed to the
        // OWNING GROUP channel; a publish_group on that group is delivered.
        let app = AppId::new();
        let group = picloud_shared::GroupId::new();
        let broadcaster = Arc::new(InProcessBroadcaster::new(8));
        let st = RealtimeState {
            app_domains: domains("app.example.com", app),
            broadcaster: broadcaster.clone(),
            authority: Arc::new(StubAuthority(Ok(()), group)),
            heartbeat: Duration::from_millis(100),
        };
        let appr = realtime_router(st);
        let req = Request::builder()
            .uri("/realtime/shared/topics/events")
            .header("host", "app.example.com")
            .body(Body::empty())
            .unwrap();
        let resp = appr.oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);

        let mut body = resp.into_body().into_data_stream();
        tokio::time::sleep(Duration::from_millis(50)).await;
        broadcaster
            .publish_group(
                group,
                "events",
                RealtimeEvent {
                    topic: "events".into(),
                    message: serde_json::json!({ "hi": 2 }),
                    published_at: chrono::Utc::now(),
                },
            )
            .await;
        let chunk = tokio::time::timeout(Duration::from_secs(2), body.next())
            .await
            .expect("a chunk within timeout")
            .expect("stream item")
            .expect("chunk ok");
        let text = String::from_utf8_lossy(&chunk);
        assert!(text.contains("\"hi\":2"), "got: {text}");
    }

    #[tokio::test]
    async fn heartbeat_fires_on_idle_connection() {
        let app = AppId::new();
        let broadcaster = Arc::new(InProcessBroadcaster::new(8));
        // Hold a clone so the channel's sender outlives the router (which
        // oneshot consumes) — otherwise the stream closes immediately.
        let _keepalive = broadcaster.clone();
        let st = state(app, "app.example.com", Ok(()), broadcaster);
        let appr = realtime_router(st);
        let req = Request::builder()
            .uri("/realtime/topics/chat")
            .header("host", "app.example.com")
            .body(Body::empty())
            .unwrap();
        let resp = appr.oneshot(req).await.unwrap();
        let mut body = resp.into_body().into_data_stream();
        // No publish — with a 100ms heartbeat, a keep-alive comment must
        // arrive well within a second.
        let chunk = tokio::time::timeout(Duration::from_secs(1), body.next())
            .await
            .expect("heartbeat within timeout")
            .expect("stream item")
            .expect("chunk ok");
        let text = String::from_utf8_lossy(&chunk);
        assert!(text.starts_with(':'), "expected SSE comment, got: {text}");
    }
}
