//! Integration tests for `RegisteredClientsClient` contract-lock operations
//! and registration, plus `tokens.revoke_by_email`.
//!
//! Lock request/response shapes mirror
//! `crates/akribes-server/src/handlers/clients.rs` and the `ContractLockInfo`
//! / `RebindLockRequest` models. The project-scoped lock ops (`list_locks`,
//! `revoke_lock`, `rebind_lock`) had no prior coverage; the flat cross-project
//! variants are covered in `lib.rs`.

use akribes_sdk::AkribesClient;
use akribes_sdk::models::ClientInterest;
use mockito::{Matcher, Server};
use std::collections::HashMap;

fn make_client(server: &Server) -> AkribesClient {
    AkribesClient::builder(server.url())
        .project_id(3)
        .name("lock-test")
        .id("client-xyz")
        .build()
}

fn lock_body() -> &'static str {
    r#"[{"id":1,"client_id":"client-xyz","client_name":"lock-test","script_name":"summarise",
        "channel":"production","bound_version_id":99,"lifetime":"session","drifted":false,
        "created_by":"alice","created_at":"2026-01-01T00:00:00Z","input_schema":"q:String"}]"#
}

// ── Project-scoped lock ops ──────────────────────────────────────────────────

#[tokio::test]
async fn list_locks_uses_script_scoped_route() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/3/scripts/summarise/locks")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(lock_body())
        .create_async()
        .await;

    let locks = make_client(&server)
        .project(3)
        .registered_clients()
        .list_locks("summarise")
        .await
        .unwrap();
    assert_eq!(locks.len(), 1);
    assert_eq!(locks[0].id, 1);
    assert_eq!(locks[0].bound_version_id, Some(99));
    assert!(!locks[0].drifted);
    assert_eq!(locks[0].lifetime, "session");
}

#[tokio::test]
async fn list_locks_404_is_empty() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/3/scripts/nope/locks")
        .with_status(404)
        .create_async()
        .await;
    assert!(
        make_client(&server)
            .project(3)
            .registered_clients()
            .list_locks("nope")
            .await
            .unwrap()
            .is_empty()
    );
}

#[tokio::test]
async fn list_locks_url_encodes_script_name() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/3/scripts/a%2Fb/locks")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body("[]")
        .create_async()
        .await;
    make_client(&server)
        .project(3)
        .registered_clients()
        .list_locks("a/b")
        .await
        .unwrap();
}

#[tokio::test]
async fn revoke_lock_deletes_by_id() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("DELETE", "/projects/3/scripts/summarise/locks/1")
        .with_status(204)
        .create_async()
        .await;
    make_client(&server)
        .project(3)
        .registered_clients()
        .revoke_lock("summarise", 1)
        .await
        .unwrap();
}

#[tokio::test]
async fn rebind_lock_sends_explicit_version_id() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("PATCH", "/projects/3/scripts/summarise/locks/1/rebind")
        .match_body(Matcher::Json(serde_json::json!({"version_id": 100})))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"id":1,"client_id":"client-xyz","client_name":"lock-test","script_name":"summarise",
                "channel":"production","bound_version_id":100,"lifetime":"session","drifted":false,
                "created_by":"alice","created_at":"2026-01-01T00:00:00Z","input_schema":"q:String"}"#,
        )
        .create_async()
        .await;
    let lock = make_client(&server)
        .project(3)
        .registered_clients()
        .rebind_lock("summarise", 1, Some(100))
        .await
        .unwrap();
    assert_eq!(lock.bound_version_id, Some(100));
}

#[tokio::test]
async fn rebind_lock_sends_null_version_id_when_none() {
    let mut server = Server::new_async().await;
    // The server's `RebindLockRequest.version_id` has no serde default — it
    // is a REQUIRED wire field. The SDK must therefore serialise the key as
    // `null` (rebind-to-channel-head) rather than omitting it, or the server
    // would 422. This pins that contract.
    let _m = server
        .mock("PATCH", "/projects/3/scripts/summarise/locks/1/rebind")
        .match_body(Matcher::Json(serde_json::json!({"version_id": null})))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"id":1,"client_id":"client-xyz","client_name":"lock-test","script_name":"summarise",
                "channel":"production","bound_version_id":101,"lifetime":"session","drifted":false,
                "created_by":"alice","created_at":"2026-01-01T00:00:00Z","input_schema":"q:String"}"#,
        )
        .create_async()
        .await;
    let lock = make_client(&server)
        .project(3)
        .registered_clients()
        .rebind_lock("summarise", 1, None)
        .await
        .unwrap();
    assert_eq!(lock.bound_version_id, Some(101));
}

// ── Registration ─────────────────────────────────────────────────────────────

#[tokio::test]
async fn init_posts_id_name_and_interests() {
    let mut server = Server::new_async().await;
    // The register body carries the client's own id + name (from the
    // builder) plus the interests list. Assert the SDK threads the
    // builder-configured id/name through.
    let _m = server
        .mock("POST", "/projects/3/clients")
        .match_body(Matcher::PartialJson(serde_json::json!({
            "id": "client-xyz",
            "name": "lock-test",
            "interests": [{"script_name": "summarise", "inputs": {"q": "String"}}],
        })))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"interests":[{"script_name":"summarise","channel":"production",
                "bound_version_id":99,"input_schema":[["q","String"]]}]}"#,
        )
        .create_async()
        .await;

    let client = make_client(&server);
    let resp = client
        .project(3)
        .registered_clients()
        .init(vec![ClientInterest {
            script_name: "summarise".into(),
            inputs: HashMap::from([("q".to_string(), "String".to_string())]),
            channel: None,
            lifetime: None,
            strict: None,
        }])
        .await
        .unwrap();
    assert_eq!(resp.interests.len(), 1);
    assert_eq!(resp.interests[0].script_name, "summarise");
    assert_eq!(resp.interests[0].bound_version_id, Some(99));
    // Tear down the heartbeat task spawned by init().
    client.project(3).registered_clients().destroy();
}

// ── tokens.revoke_by_email ───────────────────────────────────────────────────

#[tokio::test]
async fn revoke_by_email_sends_query_param_and_parses_count() {
    let mut server = Server::new_async().await;
    // DELETE /tokens?user_email=<email>; response {"revoked": N}.
    let _m = server
        .mock("DELETE", "/tokens")
        .match_query(Matcher::UrlEncoded(
            "user_email".into(),
            "alice@acme.com".into(),
        ))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":4}"#)
        .create_async()
        .await;

    let resp = make_client(&server)
        .tokens()
        .revoke_by_email("alice@acme.com")
        .await
        .unwrap();
    assert_eq!(resp.revoked, 4);
}

#[tokio::test]
async fn revoke_by_email_url_encodes_plus_and_at() {
    let mut server = Server::new_async().await;
    // `+` in an email local-part must be percent-encoded, not left raw
    // (a raw `+` decodes to a space server-side and would revoke the
    // wrong account). mockito's UrlEncoded matcher decodes before
    // comparing, so this asserts the decoded value round-trips correctly.
    let _m = server
        .mock("DELETE", "/tokens")
        .match_query(Matcher::UrlEncoded(
            "user_email".into(),
            "a+b@acme.com".into(),
        ))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":1}"#)
        .create_async()
        .await;
    let resp = make_client(&server)
        .tokens()
        .revoke_by_email("a+b@acme.com")
        .await
        .unwrap();
    assert_eq!(resp.revoked, 1);
}

#[tokio::test]
async fn revoke_by_email_403_is_fatal() {
    let mut server = Server::new_async().await;
    // Only service tokens can revoke by email; the server returns 403 for
    // scoped tokens, which the SDK classifies as Fatal.
    let _m = server
        .mock("DELETE", "/tokens")
        .match_query(Matcher::Any)
        .with_status(403)
        .with_body("Only service tokens can revoke tokens by email")
        .create_async()
        .await;
    let err = make_client(&server)
        .tokens()
        .revoke_by_email("alice@acme.com")
        .await
        .unwrap_err();
    assert!(
        matches!(err, akribes_sdk::AkribesError::Fatal { .. }),
        "got {err:?}"
    );
}
