//! Authenticated HTTP client for talking to the compliance-agent.
//!
//! Every dashboard server function that hits `comp-dev.meghsakha.com/api/v1/*`
//! must go through here so the Keycloak access token from the user's
//! session is attached as `Authorization: Bearer <token>`. Without it
//! the agent's M7.1 `require_jwt_auth` middleware rejects with 401
//! "Missing authorization header".
//!
//! When Keycloak is not configured (dev convenience), the helper
//! returns an unauthenticated builder — matching the agent's
//! pass-through behavior in the same state.
//!
//! **Token refresh**: KC access tokens are short-lived (5 min default
//! in the certifai realm). Before attaching, we decode the JWT's `exp`
//! claim and proactively refresh via the stored refresh_token if the
//! access token is expired or about to expire. The session is updated
//! with the new pair. If refresh fails, we send the (stale) token
//! anyway — the agent's 401 will surface to the UI, which can prompt
//! re-login.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use dioxus::prelude::ServerFnError;
use dioxus_fullstack::FullstackContext;
use reqwest::Method;

use super::auth::LOGGED_IN_USER_SESS_KEY;
use super::server_state::ServerState;
use super::user_state::UserStateInner;

/// Seconds before the JWT's `exp` time at which we consider it stale
/// enough to refresh. Covers clock skew + the round-trip to the agent
/// so the token doesn't expire mid-flight.
const REFRESH_SKEW_SECS: i64 = 30;

/// Build a `RequestBuilder` for `<agent_api_url><path>` with the
/// session's access token attached. `path` should include a leading
/// `/`, e.g. `"/api/v1/repositories"`.
pub async fn agent_request(
    method: Method,
    path: &str,
) -> Result<reqwest::RequestBuilder, ServerFnError> {
    let state: ServerState = FullstackContext::extract().await?;
    let url = format!("{}{}", state.agent_api_url, path);
    let mut req = reqwest::Client::new().request(method, &url);
    req = attach_token(req, &state).await?;
    Ok(req)
}

/// Same as [`agent_request`] but for `GET`. Convenience for the common case.
pub async fn agent_get(path: &str) -> Result<reqwest::RequestBuilder, ServerFnError> {
    agent_request(Method::GET, path).await
}

/// Attach the session's bearer token if Keycloak is configured AND the
/// session has a logged-in user. Refresh the token proactively if it's
/// expired or about to expire. Persists refreshed tokens back into the
/// session.
async fn attach_token(
    req: reqwest::RequestBuilder,
    state: &ServerState,
) -> Result<reqwest::RequestBuilder, ServerFnError> {
    if state.keycloak.is_none() {
        return Ok(req);
    }
    let session: tower_sessions::Session = FullstackContext::extract().await?;
    let user: Option<UserStateInner> = session
        .get(LOGGED_IN_USER_SESS_KEY)
        .await
        .map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
    let Some(mut user) = user else {
        return Ok(req);
    };

    if token_needs_refresh(&user.access_token) {
        tracing::debug!("Access token expired or near-expiring; refreshing");
        match refresh_tokens(state, &user.refresh_token).await {
            Ok((new_access, new_refresh)) => {
                user.access_token = new_access;
                if let Some(rt) = new_refresh {
                    user.refresh_token = rt;
                }
                if let Err(e) = session.insert(LOGGED_IN_USER_SESS_KEY, &user).await {
                    tracing::warn!("Failed to persist refreshed tokens: {e}");
                }
            }
            Err(e) => {
                tracing::warn!("Token refresh failed: {e}; sending current token anyway");
                // Fall through — the agent will 401 and the UI will
                // prompt re-login. Better than failing the request at
                // the dashboard layer with no helpful UX cue.
            }
        }
    }

    Ok(req.bearer_auth(user.access_token))
}

/// Decode the JWT's payload (no signature verification — the agent
/// does that) and check the `exp` claim. Treats malformed tokens as
/// expired so the refresh path runs.
fn token_needs_refresh(jwt: &str) -> bool {
    let Some(payload_b64) = jwt.split('.').nth(1) else {
        return true;
    };
    let Ok(bytes) = URL_SAFE_NO_PAD.decode(payload_b64) else {
        return true;
    };
    #[derive(serde::Deserialize)]
    struct ExpClaim {
        exp: i64,
    }
    let Ok(claims) = serde_json::from_slice::<ExpClaim>(&bytes) else {
        return true;
    };
    let now = chrono::Utc::now().timestamp();
    claims.exp - REFRESH_SKEW_SECS <= now
}

/// Exchange a refresh_token for a new access_token. Returns the new
/// access_token and (optionally) the new refresh_token KC issued.
/// KC may rotate refresh_tokens on use; we honor whatever it sends.
async fn refresh_tokens(
    state: &ServerState,
    refresh_token: &str,
) -> Result<(String, Option<String>), String> {
    let kc = state
        .keycloak
        .ok_or_else(|| "Keycloak not configured".to_string())?;
    if refresh_token.is_empty() {
        return Err("no refresh_token in session".to_string());
    }

    #[derive(serde::Deserialize)]
    struct TokenResp {
        access_token: String,
        refresh_token: Option<String>,
    }

    let resp = reqwest::Client::new()
        .post(kc.token_endpoint())
        .form(&[
            ("grant_type", "refresh_token"),
            ("client_id", kc.client_id.as_str()),
            ("refresh_token", refresh_token),
        ])
        .send()
        .await
        .map_err(|e| format!("refresh request failed: {e}"))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("refresh rejected ({status}): {body}"));
    }

    let r: TokenResp = resp
        .json()
        .await
        .map_err(|e| format!("refresh response parse failed: {e}"))?;
    Ok((r.access_token, r.refresh_token))
}

#[cfg(test)]
mod tests {
    use super::*;
    use base64::Engine;

    /// Build a JWT-shaped string (header.payload.sig) with the given
    /// payload. Signature is bogus — we never verify it locally.
    fn make_jwt(payload: &serde_json::Value) -> String {
        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload).unwrap());
        format!("hdr.{payload_b64}.sig")
    }

    #[test]
    fn token_needs_refresh_true_when_expired() {
        let exp = chrono::Utc::now().timestamp() - 60;
        let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
        assert!(token_needs_refresh(&jwt));
    }

    #[test]
    fn token_needs_refresh_true_within_skew_window() {
        // 10 seconds left; less than the 30s skew → must refresh.
        let exp = chrono::Utc::now().timestamp() + 10;
        let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
        assert!(token_needs_refresh(&jwt));
    }

    #[test]
    fn token_needs_refresh_false_with_plenty_of_life() {
        let exp = chrono::Utc::now().timestamp() + 600;
        let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
        assert!(!token_needs_refresh(&jwt));
    }

    #[test]
    fn token_needs_refresh_true_on_malformed_jwt() {
        assert!(token_needs_refresh(""));
        assert!(token_needs_refresh("not.a.jwt"));
        assert!(token_needs_refresh("only-one-segment"));
        assert!(token_needs_refresh("hdr.not-base64!.sig"));
    }

    #[test]
    fn token_needs_refresh_true_when_exp_missing() {
        let jwt = make_jwt(&serde_json::json!({ "sub": "abc" }));
        assert!(token_needs_refresh(&jwt));
    }
}
