//! Contract tests for IdpClient trait.
//!
//! These tests verify that any implementation of IdpClient adheres to the
//! expected behavior defined by the trait contract.
//!
//! Run with: cargo test --features test-utils

#![cfg(feature = "test-utils")]

use lastid_sdk::error::HttpError;
use lastid_sdk::http::{IdpClient, MockIdpClient};
use lastid_sdk::types::{
    CredentialPolicyRequest, CredentialRequestResponse, RequestId, RequestStatus,
};
use lastid_sdk::verification::VerifiablePresentation;

/// Helper to create a test policy request.
fn create_test_policy() -> CredentialPolicyRequest {
    CredentialPolicyRequest::new("BaseCredential".to_string())
        .with_callback("https://example.com/callback")
}

/// Helper to create a test credential request response.
fn create_test_response(request_id: &str) -> CredentialRequestResponse {
    CredentialRequestResponse::new(
        RequestId::new(request_id.to_string()),
        format!("openid4vp://authorize?request_uri=https://test.lastid.co/requests/{request_id}"),
        300,
        "nonce_test".to_string(),
    )
}

/// Helper to create a test SD-JWT for presentations.
fn create_test_sdjwt() -> String {
    // Minimal valid SD-JWT structure: header.payload.signature
    // header: {"alg":"ES256","typ":"sd-jwt+jwt"}
    // payload: {"iss":"did:lastid:test","sub":"did:lastid:user","iat":1705000000,"
    // exp":1705086400}
    let header = base64_url_encode(r#"{"alg":"ES256","typ":"sd-jwt+jwt"}"#);
    let payload = base64_url_encode(
        r#"{"iss":"did:lastid:test","sub":"did:lastid:user","iat":1705000000,"exp":1705086400}"#,
    );
    let signature = base64_url_encode("test_signature");
    format!("{header}.{payload}.{signature}")
}

fn base64_url_encode(data: &str) -> String {
    use base64::Engine;
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    URL_SAFE_NO_PAD.encode(data.as_bytes())
}

// =============================================================================
// Contract: request_credential
// =============================================================================

/// Contract: request_credential returns CredentialRequestResponse on success.
#[tokio::test]
async fn contract_request_credential_returns_response_on_success() {
    let mock = MockIdpClient::new();
    let expected_response = create_test_response("req_contract_test");
    mock.expect_request(Ok(expected_response));

    let policy = create_test_policy();
    let result: Result<CredentialRequestResponse, HttpError> = mock
        .request_credential(policy, "test_bearer_token", "dpop_proof")
        .await;

    assert!(result.is_ok());
    let response = result.unwrap();
    assert_eq!(response.request_id.as_str(), "req_contract_test");
    assert!(response.request_uri.starts_with("openid4vp://"));
    assert_eq!(response.expires_in, 300);
}

/// Contract: request_credential returns HttpError::Status{400} for invalid
/// policy.
#[tokio::test]
async fn contract_request_credential_returns_400_for_invalid_policy() {
    let mock = MockIdpClient::new();
    mock.expect_request(Err(HttpError::status(400, "Invalid policy")));

    let policy = create_test_policy();
    let result: Result<CredentialRequestResponse, HttpError> = mock
        .request_credential(policy, "test_bearer_token", "dpop_proof")
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    match err {
        HttpError::Status { status, message } => {
            assert_eq!(status, 400);
            assert_eq!(message, "Invalid policy");
        }
        _ => panic!("Expected Status error, got: {err:?}"),
    }
}

/// Contract: request_credential returns HttpError::Status{401} for invalid
/// DPoP.
#[tokio::test]
async fn contract_request_credential_returns_401_for_invalid_dpop() {
    let mock = MockIdpClient::new();
    mock.expect_request(Err(HttpError::status(401, "Invalid DPoP proof")));

    let policy = create_test_policy();
    let result: Result<CredentialRequestResponse, HttpError> = mock
        .request_credential(policy, "test_bearer_token", "invalid_dpop")
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    match err {
        HttpError::Status { status, .. } => {
            assert_eq!(status, 401);
        }
        _ => panic!("Expected Status error, got: {err:?}"),
    }
}

/// Contract: request_credential returns HttpError::RateLimited for 429.
#[tokio::test]
async fn contract_request_credential_returns_rate_limited_for_429() {
    let mock = MockIdpClient::new();
    mock.expect_request(Err(HttpError::rate_limited(60)));

    let policy = create_test_policy();
    let result: Result<CredentialRequestResponse, HttpError> = mock
        .request_credential(policy, "test_bearer_token", "dpop_proof")
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.is_rate_limited());
}

/// Contract: request_credential returns HttpError::Network for network
/// failures.
#[tokio::test]
async fn contract_request_credential_returns_network_error_for_failures() {
    let mock = MockIdpClient::new();
    mock.expect_request(Err(HttpError::network("Connection refused")));

    let policy = create_test_policy();
    let result: Result<CredentialRequestResponse, HttpError> = mock
        .request_credential(policy, "test_bearer_token", "dpop_proof")
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.is_retryable());
}

// =============================================================================
// Contract: poll_status
// =============================================================================

/// Contract: poll_status returns Pending status correctly.
#[tokio::test]
async fn contract_poll_status_returns_pending() {
    let mock = MockIdpClient::new();
    let request_id = RequestId::new("req_pending".to_string());
    mock.expect_poll(Ok(RequestStatus::Pending {
        request_id: request_id.clone(),
        created_at: "2026-01-16T12:00:00Z".to_string(),
    }));

    let result: Result<RequestStatus, HttpError> =
        mock.poll_status(&request_id, "test-client-id").await;

    assert!(result.is_ok());
    let status = result.unwrap();
    assert!(!status.is_terminal());
    assert_eq!(status.request_id().as_str(), "req_pending");
}

/// Contract: poll_status returns Fulfilled status with presentation.
#[tokio::test]
async fn contract_poll_status_returns_fulfilled() {
    let mock = MockIdpClient::new();
    let request_id = RequestId::new("req_fulfilled".to_string());
    mock.expect_poll(Ok(RequestStatus::Fulfilled {
        request_id: request_id.clone(),
        presentation: "sdjwt.presentation.data".to_string(),
        fulfilled_at: "2026-01-16T12:05:00Z".to_string(),
    }));

    let result: Result<RequestStatus, HttpError> =
        mock.poll_status(&request_id, "test-client-id").await;

    assert!(result.is_ok());
    let status = result.unwrap();
    assert!(status.is_terminal());
    if let RequestStatus::Fulfilled { presentation, .. } = status {
        assert!(!presentation.is_empty());
    } else {
        panic!("Expected Fulfilled status");
    }
}

/// Contract: poll_status returns Denied status with reason.
#[tokio::test]
async fn contract_poll_status_returns_denied() {
    let mock = MockIdpClient::new();
    let request_id = RequestId::new("req_denied".to_string());
    mock.expect_poll(Ok(RequestStatus::Denied {
        request_id: request_id.clone(),
        reason: "User declined".to_string(),
        denied_at: "2026-01-16T12:03:00Z".to_string(),
    }));

    let result: Result<RequestStatus, HttpError> =
        mock.poll_status(&request_id, "test-client-id").await;

    assert!(result.is_ok());
    let status = result.unwrap();
    assert!(status.is_terminal());
    if let RequestStatus::Denied { reason, .. } = status {
        assert_eq!(reason, "User declined");
    } else {
        panic!("Expected Denied status");
    }
}

/// Contract: poll_status returns Expired status.
#[tokio::test]
async fn contract_poll_status_returns_expired() {
    let mock = MockIdpClient::new();
    let request_id = RequestId::new("req_expired".to_string());
    mock.expect_poll(Ok(RequestStatus::Expired {
        request_id: request_id.clone(),
        expired_at: "2026-01-16T12:05:00Z".to_string(),
    }));

    let result: Result<RequestStatus, HttpError> =
        mock.poll_status(&request_id, "test-client-id").await;

    assert!(result.is_ok());
    let status = result.unwrap();
    assert!(status.is_terminal());
}

/// Contract: poll_status returns 404 for unknown request ID.
#[tokio::test]
async fn contract_poll_status_returns_404_for_unknown_id() {
    let mock = MockIdpClient::new();
    mock.expect_poll(Err(HttpError::status(404, "Request not found")));

    let request_id = RequestId::new("unknown_id".to_string());
    let result: Result<RequestStatus, HttpError> =
        mock.poll_status(&request_id, "test-client-id").await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    match err {
        HttpError::Status { status, .. } => {
            assert_eq!(status, 404);
        }
        _ => panic!("Expected Status error, got: {err:?}"),
    }
}

// =============================================================================
// Contract: get_presentation
// =============================================================================

/// Contract: get_presentation returns VerifiablePresentation on success.
#[tokio::test]
async fn contract_get_presentation_returns_presentation() {
    let mock = MockIdpClient::new();
    let request_id = RequestId::new("req_fulfilled".to_string());
    let sdjwt = create_test_sdjwt();
    let vp = VerifiablePresentation::parse(&sdjwt).unwrap();
    mock.expect_presentation(Ok(vp));

    let result: Result<VerifiablePresentation, HttpError> =
        mock.get_presentation(&request_id, "test-client-id").await;

    assert!(result.is_ok());
}

/// Contract: get_presentation returns 404 for unfulfilled request.
#[tokio::test]
async fn contract_get_presentation_returns_404_for_unfulfilled() {
    let mock = MockIdpClient::new();
    mock.expect_presentation(Err(HttpError::status(404, "Request not fulfilled")));

    let request_id = RequestId::new("req_pending".to_string());
    let result: Result<VerifiablePresentation, HttpError> =
        mock.get_presentation(&request_id, "test-client-id").await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    match err {
        HttpError::Status { status, .. } => {
            assert_eq!(status, 404);
        }
        _ => panic!("Expected Status error, got: {err:?}"),
    }
}

/// Contract: get_presentation returns 410 for already consumed presentation.
#[tokio::test]
async fn contract_get_presentation_returns_410_for_consumed() {
    let mock = MockIdpClient::new();
    mock.expect_presentation(Err(HttpError::status(410, "Presentation already consumed")));

    let request_id = RequestId::new("req_consumed".to_string());
    let result: Result<VerifiablePresentation, HttpError> =
        mock.get_presentation(&request_id, "test-client-id").await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    match err {
        HttpError::Status { status, .. } => {
            assert_eq!(status, 410);
        }
        _ => panic!("Expected Status error, got: {err:?}"),
    }
}

// =============================================================================
// Contract: Error retryability
// =============================================================================

/// Contract: Network errors are retryable.
#[tokio::test]
async fn contract_network_errors_are_retryable() {
    let err = HttpError::network("Connection failed");
    assert!(err.is_retryable());
}

/// Contract: Timeout errors are retryable.
#[tokio::test]
async fn contract_timeout_errors_are_retryable() {
    let err = HttpError::Timeout;
    assert!(err.is_retryable());
}

/// Contract: 5xx errors are retryable.
#[tokio::test]
async fn contract_5xx_errors_are_retryable() {
    let err = HttpError::status(500, "Internal server error");
    assert!(err.is_retryable());

    let err = HttpError::status(503, "Service unavailable");
    assert!(err.is_retryable());
}

/// Contract: 4xx errors (except 429) are not retryable.
#[tokio::test]
async fn contract_4xx_errors_not_retryable() {
    let err = HttpError::status(400, "Bad request");
    assert!(!err.is_retryable());

    let err = HttpError::status(401, "Unauthorized");
    assert!(!err.is_retryable());

    let err = HttpError::status(404, "Not found");
    assert!(!err.is_retryable());
}

/// Contract: Rate limited errors are retryable with backoff.
#[tokio::test]
async fn contract_rate_limited_is_retryable() {
    let err = HttpError::rate_limited(60);
    assert!(err.is_rate_limited());
}
