//! Tests for `S3Backend`.
//!
//! Note: These tests require a running S3-compatible service (S3, `MinIO`, etc.).
//! To run with `MinIO` locally:
//!
//! ```bash
//! docker run -d -p 9000:9000 -p 9001:9001 minio/minio server /data
//! export AWS_ACCESS_KEY_ID=minioadmin
//! export AWS_SECRET_ACCESS_KEY=minioadmin
//! cargo test -p fraiseql-storage --lib --features aws-s3 s3::tests
//! ```

#![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
#![allow(missing_docs)] // Reason: test functions are self-describing
#![allow(clippy::indexing_slicing)] // Reason: test fixtures index into known-shape collections; OOB indices correctly fail the test

use crate::backend::S3Backend;

/// Helper to skip tests if S3 service is not configured.
fn skip_if_no_s3() -> Option<()> {
    if std::env::var("S3_ENDPOINT").is_err() && std::env::var("AWS_ENDPOINT_URL").is_err() {
        return None;
    }
    Some(())
}

/// Helper to create an `S3Backend` for testing.
///
/// When an S3-compatible endpoint is configured (CI binds `MinIO` and sets
/// `S3_ENDPOINT`), the unique per-test bucket is provisioned before the backend is
/// returned — otherwise every `put_object`/`get_object` below fails `NoSuchBucket`,
/// because the backend constructor never creates the bucket itself.
fn create_test_backend() -> S3Backend {
    let endpoint = std::env::var("S3_ENDPOINT").or_else(|_| std::env::var("AWS_ENDPOINT_URL")).ok();

    // Use a unique bucket name for tests to avoid conflicts
    let bucket = format!("test-{}", uuid::Uuid::new_v4());

    let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
    rt.block_on(async {
        if let Some(ep) = endpoint.as_deref() {
            ensure_test_bucket(ep, &bucket).await;
        }
        S3Backend::new(&bucket, None, endpoint.as_deref()).await
    })
}

/// Create the per-test bucket against the configured S3 endpoint, tolerating a bucket we
/// already own (idempotent). The backend resolves credentials and region from the AWS
/// environment chain (CI injects `minioadmin` / `us-east-1`), so this provisioning client
/// is built the same way `S3Backend::new` builds its own.
async fn ensure_test_bucket(endpoint: &str, bucket: &str) {
    let config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await;
    let s3_config = aws_sdk_s3::config::Builder::from(&config)
        .endpoint_url(endpoint)
        .force_path_style(true)
        .build();
    let client = aws_sdk_s3::Client::from_conf(s3_config);

    if let Err(err) = client.create_bucket().bucket(bucket).send().await {
        // An already-owned/existing bucket is fine; detect it structurally rather than by
        // string-matching the SdkError Display. Any other error is a real provisioning
        // failure and must fail the test loudly.
        let already_ours = err
            .as_service_error()
            .is_some_and(|e| e.is_bucket_already_owned_by_you() || e.is_bucket_already_exists());
        assert!(already_ours, "create test bucket {bucket} failed: {err:?}");
    }
}

#[test]
fn test_s3_backend_struct_creation() {
    // This test just verifies that the S3Backend struct can be created
    // It doesn't require external services
    let _backend = create_test_backend();
    // If we get here, the test passed (no panic)
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_put_and_get_roundtrip() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "test-file.txt";
        let data = b"Hello, S3 world!";
        let content_type = "text/plain";

        // Upload test data
        let result = backend.upload(key, data, content_type).await;
        assert!(result.is_ok(), "upload should succeed");
        assert_eq!(result.unwrap(), key);

        // Download and verify
        let downloaded = backend.download(key).await;
        assert!(downloaded.is_ok(), "download should succeed");
        assert_eq!(downloaded.unwrap(), data);
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_get_nonexistent_returns_not_found() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let result = backend.download("nonexistent-key.txt").await;
        assert!(result.is_err(), "download of nonexistent key should fail");

        // Structural detection (H40): a missing key surfaces the typed
        // `FileError::NotFound`, not a generic backend error string-matched on
        // the SdkError Display.
        let err = result.unwrap_err();
        assert!(
            matches!(
                err,
                fraiseql_error::FraiseQLError::File(fraiseql_error::FileError::NotFound { .. })
            ),
            "missing key must map to FileError::NotFound, got: {err:?}"
        );
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_delete_removes_object() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "to-delete.txt";
        let data = b"temporary file";

        // Upload
        backend.upload(key, data, "text/plain").await.expect("upload succeeds");

        // Verify exists
        let exists = backend.exists(key).await.expect("exists check succeeds");
        assert!(exists, "file should exist after upload");

        // Delete
        let delete_result = backend.delete(key).await;
        assert!(delete_result.is_ok(), "delete should succeed");

        // Verify deleted
        let exists_after = backend.exists(key).await.expect("exists check succeeds");
        assert!(!exists_after, "file should not exist after delete");
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_list_with_prefix() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Upload files with different prefixes
        backend
            .upload("avatars/user1.jpg", b"avatar 1", "image/jpeg")
            .await
            .expect("upload 1");
        backend
            .upload("avatars/user2.jpg", b"avatar 2", "image/jpeg")
            .await
            .expect("upload 2");
        backend
            .upload("documents/doc.pdf", b"pdf content", "application/pdf")
            .await
            .expect("upload 3");

        // List with "avatars/" prefix
        let result = backend.list("avatars/", None, 100).await.expect("list succeeds");

        assert_eq!(result.objects.len(), 2, "should have 2 items under avatars/");
        assert!(
            result.objects.iter().any(|o| o.key == "avatars/user1.jpg"),
            "should include avatars/user1.jpg"
        );
        assert!(
            result.objects.iter().any(|o| o.key == "avatars/user2.jpg"),
            "should include avatars/user2.jpg"
        );
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_list_pagination() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Upload 5 objects
        for i in 0..5 {
            let key = format!("file{:02}.txt", i);
            backend
                .upload(&key, format!("data {}", i).as_bytes(), "text/plain")
                .await
                .expect("upload succeeds");
        }

        // First page with limit=2
        let page1 = backend.list("", None, 2).await.expect("list page 1 succeeds");
        assert_eq!(page1.objects.len(), 2, "first page should have 2 items");

        let cursor1 = page1.next_cursor.expect("first page should have cursor");

        // Second page using cursor
        let page2 = backend.list("", Some(&cursor1), 2).await.expect("list page 2 succeeds");
        assert_eq!(page2.objects.len(), 2, "second page should have 2 items");

        // Verify pages don't overlap
        assert_ne!(
            page1.objects[0].key, page2.objects[0].key,
            "pages should have different objects"
        );

        // Third page (remaining items)
        let cursor2 = page2.next_cursor.expect("second page should have cursor");
        let page3 = backend.list("", Some(&cursor2), 2).await.expect("list page 3 succeeds");
        assert_eq!(page3.objects.len(), 1, "third page should have 1 item");
        assert!(page3.next_cursor.is_none(), "last page should have no cursor");
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_exists_true_and_false() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "existence-test.txt";

        // Before upload, should not exist
        let exists_before = backend.exists(key).await.expect("exists check succeeds");
        assert!(!exists_before, "should not exist before upload");

        // Upload
        backend.upload(key, b"test", "text/plain").await.expect("upload succeeds");

        // After upload, should exist
        let exists_after = backend.exists(key).await.expect("exists check succeeds");
        assert!(exists_after, "should exist after upload");

        // Non-existent key should return false, not error
        let not_exist = backend
            .exists("definitely-does-not-exist.txt")
            .await
            .expect("exists check on non-existent key should not error");
        assert!(!not_exist, "non-existent key should return false");
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_large_object_streaming() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Create 10MB of data to test streaming
        let large_data = vec![42u8; 10 * 1024 * 1024];
        let key = "large-file.bin";

        // Upload large object
        let upload_result = backend.upload(key, &large_data, "application/octet-stream").await;
        assert!(upload_result.is_ok(), "large upload should succeed");

        // Download and verify size
        let downloaded = backend.download(key).await.expect("large download should succeed");
        assert_eq!(
            downloaded.len(),
            large_data.len(),
            "downloaded size should match uploaded size"
        );
        assert_eq!(downloaded, large_data, "downloaded content should match uploaded content");
    });
}

#[test]
fn test_s3_key_validation() {
    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Test empty key
        let result = backend.upload("", b"data", "text/plain").await;
        assert!(result.is_err(), "empty key should be rejected");

        // Test path traversal
        let result = backend.upload("../etc/passwd", b"data", "text/plain").await;
        assert!(result.is_err(), "path traversal should be rejected");

        // Test absolute path
        let result = backend.upload("/etc/passwd", b"data", "text/plain").await;
        assert!(result.is_err(), "absolute path should be rejected");
    });
}

// ============================================================================
// PRESIGNED URL TESTS (Phase 2, Cycle 2)
// ============================================================================

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_upload_returns_valid_url() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "presign-upload-test.txt";

        // Generate presigned URL for upload (1 hour expiry)
        let presigned_result =
            backend.presigned_url(key, std::time::Duration::from_secs(3600)).await;

        assert!(presigned_result.is_ok(), "presigned URL generation should succeed");

        let url = presigned_result.unwrap();
        assert!(
            url.starts_with("http://") || url.starts_with("https://"),
            "presigned URL should be a valid HTTP URL"
        );
        assert!(
            url.contains(key) || url.contains("presign-upload-test"),
            "presigned URL should contain the key"
        );
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_download_returns_valid_url() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "presign-download-test.txt";
        let data = b"presigned download content";

        // Upload file first
        backend.upload(key, data, "text/plain").await.expect("upload succeeds");

        // Generate presigned URL for download
        let presigned_result =
            backend.presigned_url(key, std::time::Duration::from_secs(3600)).await;

        assert!(presigned_result.is_ok(), "presigned URL generation should succeed");

        let url = presigned_result.unwrap();
        assert!(
            url.starts_with("http://") || url.starts_with("https://"),
            "presigned URL should be a valid HTTP URL"
        );
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_url_expires() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "presign-expire-test.txt";

        // Generate presigned URL with 1 second expiry
        let presigned_result = backend.presigned_url(key, std::time::Duration::from_secs(1)).await;

        assert!(presigned_result.is_ok(), "presigned URL generation should succeed");

        // URL should be valid immediately
        let url = presigned_result.unwrap();
        assert!(!url.is_empty(), "presigned URL should not be empty");

        // Sleep for 2 seconds - URL should expire
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        // Note: We can't directly test URL expiry without actually using it,
        // but the URL structure should be correct with X-Amz-Expires parameter
        assert!(
            url.contains("X-Amz-Expires") || url.contains("expires"),
            "presigned URL should contain expiry information"
        );
    });
}

#[test]
fn test_s3_presign_rejects_invalid_keys() {
    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Test path traversal in presigned URL
        let result = backend
            .presigned_url("../etc/passwd", std::time::Duration::from_secs(3600))
            .await;
        assert!(result.is_err(), "path traversal should be rejected");

        // Test empty key
        let result = backend.presigned_url("", std::time::Duration::from_secs(3600)).await;
        assert!(result.is_err(), "empty key should be rejected");
    });
}

#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_respects_expiry() {
    let Some(()) = skip_if_no_s3() else {
        return;
    };

    let backend = create_test_backend();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        let key = "presign-ttl-test.txt";

        // Generate URLs with different expiry times
        let short_ttl = std::time::Duration::from_secs(60);
        let long_ttl = std::time::Duration::from_secs(3600);

        let short_url = backend
            .presigned_url(key, short_ttl)
            .await
            .expect("short TTL presigned URL should succeed");

        let long_url = backend
            .presigned_url(key, long_ttl)
            .await
            .expect("long TTL presigned URL should succeed");

        // Both should be valid URLs
        assert!(!short_url.is_empty(), "short TTL URL should not be empty");
        assert!(!long_url.is_empty(), "long TTL URL should not be empty");

        // URLs should differ (different expiry times)
        assert_ne!(short_url, long_url, "URLs with different TTLs should be different");
    });
}
