//! Integration tests for `KetoClient` and `KetoLayer` against the shared Keto
//! service declared in `sunbeam.workspace.yaml`.
//!
//! # Running
//!
//! Start the workspace's Keto service first:
//!
//! ```text
//! sunbeam ops compose up keto
//! ```
//!
//! Then:
//!
//! ```text
//! cargo test -p sunbeam-g2v --test keto_integration
//! ```
//!
//! Tests skip with a clear message if Keto isn't reachable so this file doesn't
//! fail on CI when the compose stack isn't up. Each test isolates state via a
//! fresh UUID per object, so concurrent runs against the same instance are safe.
//!
//! Tests target the local Keto declared in `libs/sunbeam-g2v/.integration/`.
//! Bring it up once with:
//!
//!   cd libs/sunbeam-g2v/.integration && docker compose up -d --wait
//!
//! Defaults:
//! - `KETO_GRPC_URL` `http://localhost:5675` (CheckService)
//! - `KETO_WRITE_GRPC_URL` `http://localhost:5676` (WriteService)
//! - Namespace: `g2v_test` (declared in `.integration/keto.yaml`)

use axum::body::Body;
use axum::response::{IntoResponse, Response};
use sunbeam_g2v::middleware::auth::{AuthContext, KetoClient, KetoConfig, KetoLayer};
use tower::{Service, ServiceBuilder, ServiceExt};
use uuid::Uuid;

/// Namespace declared in `infra/sbbb/base/ory/keto-namespaces.config.ts`.
const NS: &str = "G2vTest";

struct Keto {
    grpc_endpoint: String,
    write_grpc_endpoint: String,
}

async fn probe_keto() -> Option<Keto> {
    let grpc_endpoint = std::env::var("KETO_GRPC_URL")
        .unwrap_or_else(|_| "http://localhost:4466".to_string());
    let write_grpc_endpoint = std::env::var("KETO_WRITE_GRPC_URL")
        .unwrap_or_else(|_| "http://localhost:4467".to_string());

    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: grpc_endpoint.clone(),
        write_grpc_endpoint: write_grpc_endpoint.clone(),
    });

    if let Err(e) = client
        .check_permission(NS, "probe-object", "readers", "probe-subject")
        .await
    {
        eprintln!(
            "[keto_integration] Keto not reachable at {grpc_endpoint}: {e}; skipping. \
             Run `sunbeam ops compose up keto` to enable these tests."
        );
        return None;
    }

    Some(Keto {
        grpc_endpoint,
        write_grpc_endpoint,
    })
}

/// Seed a relation tuple via the native gRPC `WriteService` client.
async fn seed_tuple(keto: &Keto, namespace: &str, object: &str, relation: &str, subject: &str) {
    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: keto.grpc_endpoint.clone(),
        write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
    });
    client
        .grant(namespace, object, relation, subject)
        .await
        .expect("seed_tuple: grant failed");
}

/// Inner service for `KetoLayer` tests — returns 200 OK.
fn ok_service() -> impl Service<
    http::Request<Body>,
    Response = Response,
    Error = std::convert::Infallible,
    Future = impl std::future::Future<Output = Result<Response, std::convert::Infallible>>,
> + Clone {
    tower::service_fn(|_req: http::Request<Body>| async {
        Ok::<_, std::convert::Infallible>(
            http::Response::builder()
                .status(http::StatusCode::OK)
                .body(Body::empty())
                .unwrap()
                .into_response(),
        )
    })
}

#[tokio::test]
async fn test_keto_check_permission_allowed() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: keto.grpc_endpoint.clone(),
        write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
    });

    let object = format!("repo:{}", Uuid::new_v4());
    let subject = format!("user:{}", Uuid::new_v4());

    seed_tuple(&keto, NS, &object, "readers", &subject).await;

    let allowed = client
        .check_permission(NS, &object, "readers", &subject)
        .await
        .expect("check_permission");

    assert!(allowed, "{subject} should have readers on {object}");
}

#[tokio::test]
async fn test_keto_check_permission_denied() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: keto.grpc_endpoint.clone(),
        write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
    });

    let object = format!("repo:{}", Uuid::new_v4());
    let other_subject = format!("user:{}", Uuid::new_v4());
    // No tuple seeded for `other_subject`.

    let allowed = client
        .check_permission(NS, &object, "readers", &other_subject)
        .await
        .expect("check_permission");

    assert!(!allowed, "{other_subject} should NOT have readers on {object}");
}

#[tokio::test]
async fn test_keto_layer_403_on_denied() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    let object = format!("repo:{}", Uuid::new_v4());
    let subject = format!("user:{}", Uuid::new_v4());
    // No tuple seeded.

    let keto_layer = KetoLayer::new(
        KetoClient::new(KetoConfig {
            grpc_endpoint: keto.grpc_endpoint.clone(),
            write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
        }),
        NS,
        "readers",
    );

    let mut svc = ServiceBuilder::new()
        .layer(keto_layer)
        .service(ok_service());

    let mut req = http::Request::builder()
        .uri(format!("/{}", object))
        .body(Body::empty())
        .unwrap();
    req.extensions_mut()
        .insert(AuthContext::authenticated(subject.clone(), None));

    let resp = svc.ready().await.unwrap().call(req).await.unwrap();
    assert_eq!(resp.status(), http::StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn test_keto_layer_passes_when_allowed() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    // The default KetoLayer object extractor uses req.uri().path(), so the
    // object we seed must match the request URI path exactly.
    let object = format!("/repo:{}", Uuid::new_v4());
    let subject = format!("user:{}", Uuid::new_v4());

    seed_tuple(&keto, NS, &object, "readers", &subject).await;

    let keto_layer = KetoLayer::new(
        KetoClient::new(KetoConfig {
            grpc_endpoint: keto.grpc_endpoint.clone(),
            write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
        }),
        NS,
        "readers",
    );

    let mut svc = ServiceBuilder::new()
        .layer(keto_layer)
        .service(ok_service());

    let mut req = http::Request::builder()
        .uri(object.clone())
        .body(Body::empty())
        .unwrap();
    req.extensions_mut()
        .insert(AuthContext::authenticated(subject.clone(), None));

    let resp = svc.ready().await.unwrap().call(req).await.unwrap();
    assert_eq!(resp.status(), http::StatusCode::OK);
}

#[tokio::test]
async fn test_keto_list_relation_tuples() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: keto.grpc_endpoint.clone(),
        write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
    });

    let object = format!("repo:{}", Uuid::new_v4());
    let subject = format!("user:{}", Uuid::new_v4());

    seed_tuple(&keto, NS, &object, "readers", &subject).await;

    let tuples = client
        .list_relation_tuples(NS, None, Some("readers"), Some(&subject))
        .await
        .expect("list_relation_tuples");

    assert!(
        tuples.iter().any(|t| t.object == object && matches!(&t.subject, sunbeam_g2v::middleware::auth::keto::Subject::Id(id) if id == &subject)),
        "expected tuple for {object}@{subject} in list results"
    );
}

#[tokio::test]
async fn test_keto_delete_relation_tuples() {
    let Some(keto) = probe_keto().await else {
        return;
    };

    let client = KetoClient::new(KetoConfig {
        grpc_endpoint: keto.grpc_endpoint.clone(),
        write_grpc_endpoint: keto.write_grpc_endpoint.clone(),
    });

    let object = format!("repo:{}", Uuid::new_v4());
    let subject = format!("user:{}", Uuid::new_v4());

    seed_tuple(&keto, NS, &object, "readers", &subject).await;

    // Ensure the tuple exists.
    let allowed = client
        .check_permission(NS, &object, "readers", &subject)
        .await
        .expect("check_permission before delete");
    assert!(allowed, "tuple should exist before delete");

    // Delete the tuple.
    client
        .delete_relation_tuples(NS, Some(&object), Some("readers"), Some(&subject))
        .await
        .expect("delete_relation_tuples");

    // Permission should now be denied.
    let allowed = client
        .check_permission(NS, &object, "readers", &subject)
        .await
        .expect("check_permission after delete");
    assert!(!allowed, "tuple should be deleted");
}
