use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use base64::engine::general_purpose::URL_SAFE;
use base64::Engine;
use http_body_util::BodyExt;
use myfsio_storage::traits::{AsyncReadStream, StorageEngine};
use serde_json::Value;
use std::collections::HashMap;
use tower::ServiceExt;

const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";

fn test_app_with_iam(iam_json: serde_json::Value) -> (axum::Router, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();

    std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        multipart_min_part_size: 1,
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);
    let app = myfsio_server::create_router(state);
    (app, tmp)
}

fn test_app_and_state() -> (
    axum::Router,
    myfsio_server::state::AppState,
    tempfile::TempDir,
) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();

    std::fs::write(
        iam_path.join("iam.json"),
        serde_json::json!({
            "version": 2,
            "users": [{
                "user_id": "u-test1234",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": TEST_ACCESS_KEY,
                    "secret_key": TEST_SECRET_KEY,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            }]
        })
        .to_string(),
    )
    .unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        multipart_min_part_size: 1,
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);
    let app = myfsio_server::create_router(state.clone());
    (app, state, tmp)
}

fn test_app() -> (axum::Router, tempfile::TempDir) {
    test_app_with_iam(serde_json::json!({
        "version": 2,
        "users": [{
            "user_id": "u-test1234",
            "display_name": "admin",
            "enabled": true,
            "access_keys": [{
                "access_key": TEST_ACCESS_KEY,
                "secret_key": TEST_SECRET_KEY,
                "status": "active"
            }],
            "policies": [{
                "bucket": "*",
                "actions": ["*"],
                "prefix": "*"
            }]
        }]
    }))
}

fn test_app_with_rate_limits(
    default: myfsio_server::config::RateLimitSetting,
    admin: myfsio_server::config::RateLimitSetting,
) -> (axum::Router, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();
    std::fs::write(
        iam_path.join("iam.json"),
        serde_json::json!({
            "version": 2,
            "users": [{
                "user_id": "u-test1234",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": TEST_ACCESS_KEY,
                    "secret_key": TEST_SECRET_KEY,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            }]
        })
        .to_string(),
    )
    .unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        iam_config_path: iam_path.join("iam.json"),
        ratelimit_default: default,
        ratelimit_list_buckets: default,
        ratelimit_bucket_ops: default,
        ratelimit_object_ops: default,
        ratelimit_head_ops: default,
        ratelimit_admin: admin,
        ui_enabled: false,
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);
    let app = myfsio_server::create_router(state);
    (app, tmp)
}

#[tokio::test]
async fn rate_limit_default_and_admin_are_independent() {
    let (app, _tmp) = test_app_with_rate_limits(
        myfsio_server::config::RateLimitSetting::new(1, 60),
        myfsio_server::config::RateLimitSetting::new(2, 60),
    );

    let first = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/myfsio/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::OK);

    let second = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/myfsio/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE);
    assert!(second.headers().contains_key("retry-after"));

    let admin_first = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/admin/gc/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(admin_first.status(), StatusCode::FORBIDDEN);

    let admin_second = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/admin/gc/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(admin_second.status(), StatusCode::FORBIDDEN);

    let admin_third = app
        .oneshot(
            Request::builder()
                .uri("/admin/gc/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(admin_third.status(), StatusCode::SERVICE_UNAVAILABLE);
}

fn test_ui_state() -> (myfsio_server::state::AppState, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();

    std::fs::write(
        iam_path.join("iam.json"),
        serde_json::json!({
            "version": 2,
            "users": [{
                "user_id": "u-test1234",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": TEST_ACCESS_KEY,
                    "secret_key": TEST_SECRET_KEY,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            }]
        })
        .to_string(),
    )
    .unwrap();

    let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 1,
        replication_read_timeout_secs: 1,
        replication_max_retries: 1,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: true,
        templates_dir: manifest_dir.join("templates"),
        static_dir: manifest_dir.join("static"),
        ..myfsio_server::config::ServerConfig::default()
    };
    (myfsio_server::state::AppState::new(config), tmp)
}

fn authenticated_ui_session(state: &myfsio_server::state::AppState) -> (String, String) {
    let (session_id, mut session) = state.sessions.create();
    session.user_id = Some(TEST_ACCESS_KEY.to_string());
    session.display_name = Some("admin".to_string());
    let csrf = session.csrf_token.clone();
    state.sessions.save(&session_id, session);
    (session_id, csrf)
}

fn ui_request(method: Method, uri: &str, session_id: &str, csrf: Option<&str>) -> Request<Body> {
    let mut builder = Request::builder().method(method).uri(uri).header(
        "cookie",
        format!(
            "{}={}",
            myfsio_server::session::SESSION_COOKIE_NAME,
            session_id
        ),
    );
    if let Some(token) = csrf {
        builder = builder.header(myfsio_server::session::CSRF_HEADER_NAME, token);
    }
    builder.body(Body::empty()).unwrap()
}

fn ui_form_request(
    method: Method,
    uri: &str,
    session_id: &str,
    csrf: &str,
    body: &str,
) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header(
            "cookie",
            format!(
                "{}={}",
                myfsio_server::session::SESSION_COOKIE_NAME,
                session_id
            ),
        )
        .header("x-csrftoken", csrf)
        .header("x-requested-with", "XMLHttpRequest")
        .header("accept", "application/json")
        .header("content-type", "application/x-www-form-urlencoded")
        .body(Body::from(body.to_string()))
        .unwrap()
}

fn ui_json_request(
    method: Method,
    uri: &str,
    session_id: &str,
    csrf: &str,
    body: &str,
) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header(
            "cookie",
            format!(
                "{}={}",
                myfsio_server::session::SESSION_COOKIE_NAME,
                session_id
            ),
        )
        .header("x-csrftoken", csrf)
        .header("x-requested-with", "XMLHttpRequest")
        .header("accept", "application/json")
        .header("content-type", "application/json")
        .body(Body::from(body.to_string()))
        .unwrap()
}

fn signed_request(method: Method, uri: &str, body: Body) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(body)
        .unwrap()
}

fn test_website_state() -> (myfsio_server::state::AppState, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();

    std::fs::write(
        iam_path.join("iam.json"),
        serde_json::json!({
            "version": 2,
            "users": [{
                "user_id": "u-test1234",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": TEST_ACCESS_KEY,
                    "secret_key": TEST_SECRET_KEY,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            }]
        })
        .to_string(),
    )
    .unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: true,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        ..myfsio_server::config::ServerConfig::default()
    };
    (myfsio_server::state::AppState::new(config), tmp)
}

async fn put_website_object(
    state: &myfsio_server::state::AppState,
    bucket: &str,
    key: &str,
    body: &str,
    content_type: &str,
) {
    let mut metadata = HashMap::new();
    metadata.insert("__content_type__".to_string(), content_type.to_string());
    let reader: AsyncReadStream = Box::pin(std::io::Cursor::new(body.as_bytes().to_vec()));
    state
        .storage
        .put_object(bucket, key, reader, Some(metadata))
        .await
        .unwrap();
}

async fn test_website_app(error_document: Option<&str>) -> (axum::Router, tempfile::TempDir) {
    let (state, tmp) = test_website_state();
    let bucket = "site-bucket";

    state.storage.create_bucket(bucket).await.unwrap();
    put_website_object(
        &state,
        bucket,
        "index.html",
        "<!doctype html><h1>Home</h1>",
        "text/html",
    )
    .await;
    if let Some(error_key) = error_document {
        put_website_object(
            &state,
            bucket,
            error_key,
            "<!doctype html><h1>Bucket Not Found Page</h1>",
            "text/html",
        )
        .await;
    }

    let mut config = state.storage.get_bucket_config(bucket).await.unwrap();
    config.website = Some(match error_document {
        Some(error_key) => serde_json::json!({
            "index_document": "index.html",
            "error_document": error_key,
        }),
        None => serde_json::json!({
            "index_document": "index.html",
        }),
    });
    state
        .storage
        .set_bucket_config(bucket, &config)
        .await
        .unwrap();
    state
        .website_domains
        .as_ref()
        .unwrap()
        .set_mapping("site.example.com", bucket);

    (myfsio_server::create_router(state), tmp)
}

fn website_request(method: Method, uri: &str) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header("Host", "site.example.com")
        .body(Body::empty())
        .unwrap()
}

fn parse_select_events(body: &[u8]) -> Vec<(String, Vec<u8>)> {
    let mut out = Vec::new();
    let mut idx: usize = 0;

    while idx + 16 <= body.len() {
        let total_len =
            u32::from_be_bytes([body[idx], body[idx + 1], body[idx + 2], body[idx + 3]]) as usize;
        let headers_len =
            u32::from_be_bytes([body[idx + 4], body[idx + 5], body[idx + 6], body[idx + 7]])
                as usize;
        if total_len < 16 || idx + total_len > body.len() {
            break;
        }

        let headers_start = idx + 12;
        let headers_end = headers_start + headers_len;
        if headers_end > idx + total_len - 4 {
            break;
        }

        let mut event_type: Option<String> = None;
        let mut hidx = headers_start;
        while hidx < headers_end {
            let name_len = body[hidx] as usize;
            hidx += 1;
            if hidx + name_len + 3 > headers_end {
                break;
            }
            let name = String::from_utf8_lossy(&body[hidx..hidx + name_len]).to_string();
            hidx += name_len;

            let value_type = body[hidx];
            hidx += 1;
            if value_type != 7 || hidx + 2 > headers_end {
                break;
            }

            let value_len = u16::from_be_bytes([body[hidx], body[hidx + 1]]) as usize;
            hidx += 2;
            if hidx + value_len > headers_end {
                break;
            }

            let value = String::from_utf8_lossy(&body[hidx..hidx + value_len]).to_string();
            hidx += value_len;

            if name == ":event-type" {
                event_type = Some(value);
            }
        }

        let payload_start = headers_end;
        let payload_end = idx + total_len - 4;
        let payload = body[payload_start..payload_end].to_vec();

        out.push((event_type.unwrap_or_default(), payload));
        idx += total_len;
    }

    out
}

#[tokio::test]
async fn test_ui_replication_endpoints_are_wired_and_operational() {
    let (state, _tmp) = test_ui_state();
    let bucket_name = "replicated-bucket";

    state
        .replication
        .set_rule(myfsio_server::services::replication::ReplicationRule {
            bucket_name: bucket_name.to_string(),
            target_connection_id: "missing-connection".to_string(),
            target_bucket: "remote-bucket".to_string(),
            enabled: true,
            mode: myfsio_server::services::replication::MODE_NEW_ONLY.to_string(),
            created_at: Some(1_700_000_000.0),
            stats: myfsio_server::services::replication::ReplicationStats {
                objects_synced: 3,
                objects_pending: 1,
                objects_orphaned: 0,
                bytes_synced: 123,
                last_sync_at: Some(1_700_000_100.0),
                last_sync_key: Some("folder/item.txt".to_string()),
            },
            sync_deletions: true,
            last_pull_at: None,
            filter_prefix: None,
        });

    state.replication.failures.add(
        bucket_name,
        myfsio_server::services::replication::ReplicationFailure {
            object_key: "folder/item.txt".to_string(),
            error_message: "temporary failure".to_string(),
            timestamp: 1_700_000_200.0,
            failure_count: 2,
            bucket_name: bucket_name.to_string(),
            action: "put".to_string(),
            last_error_code: Some("SlowDown".to_string()),
        },
    );
    state.replication.failures.add(
        bucket_name,
        myfsio_server::services::replication::ReplicationFailure {
            object_key: "other.txt".to_string(),
            error_message: "another failure".to_string(),
            timestamp: 1_700_000_300.0,
            failure_count: 1,
            bucket_name: bucket_name.to_string(),
            action: "put".to_string(),
            last_error_code: None,
        },
    );

    let (session_id, csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state.clone());

    let status_resp = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}/replication/status", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(status_resp.status(), StatusCode::OK);
    let status_body: Value =
        serde_json::from_slice(&status_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(status_body["objects_synced"], 3);
    assert_eq!(status_body["objects_pending"], 1);
    assert_eq!(status_body["endpoint_healthy"], false);
    assert_eq!(status_body["endpoint_error"], "Target connection not found");

    let failures_resp = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}/replication/failures?limit=10", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(failures_resp.status(), StatusCode::OK);
    let failures_body: Value = serde_json::from_slice(
        &failures_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes(),
    )
    .unwrap();
    assert_eq!(failures_body["total"], 2);
    assert_eq!(failures_body["failures"].as_array().unwrap().len(), 2);

    let retry_resp = app
        .clone()
        .oneshot(ui_request(
            Method::POST,
            &format!(
                "/ui/buckets/{}/replication/failures/retry?object_key=folder%2Fitem.txt",
                bucket_name
            ),
            &session_id,
            Some(&csrf),
        ))
        .await
        .unwrap();
    assert_eq!(retry_resp.status(), StatusCode::BAD_REQUEST);

    let dismiss_resp = app
        .clone()
        .oneshot(ui_request(
            Method::DELETE,
            &format!(
                "/ui/buckets/{}/replication/failures/dismiss?object_key=folder%2Fitem.txt",
                bucket_name
            ),
            &session_id,
            Some(&csrf),
        ))
        .await
        .unwrap();
    assert_eq!(dismiss_resp.status(), StatusCode::OK);
    let dismiss_body: Value =
        serde_json::from_slice(&dismiss_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(dismiss_body["status"], "dismissed");

    let clear_resp = app
        .clone()
        .oneshot(ui_request(
            Method::DELETE,
            &format!("/ui/buckets/{}/replication/failures/clear", bucket_name),
            &session_id,
            Some(&csrf),
        ))
        .await
        .unwrap();
    assert_eq!(clear_resp.status(), StatusCode::OK);
    let clear_body: Value =
        serde_json::from_slice(&clear_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(clear_body["status"], "cleared");

    let failures_after_clear = app
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}/replication/failures?limit=10", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(failures_after_clear.status(), StatusCode::OK);
    let failures_after_clear_body: Value = serde_json::from_slice(
        &failures_after_clear
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes(),
    )
    .unwrap();
    assert_eq!(failures_after_clear_body["total"], 0);
}

#[tokio::test]
async fn test_ui_replication_configuration_actions_work() {
    let (state, _tmp) = test_ui_state();
    let bucket_name = "config-bucket";
    state.storage.create_bucket(bucket_name).await.unwrap();
    state
        .connections
        .add(myfsio_server::stores::connections::RemoteConnection {
            id: "conn-1".to_string(),
            name: "Remote".to_string(),
            endpoint_url: "http://127.0.0.1:1".to_string(),
            access_key: "remote-key".to_string(),
            secret_key: "remote-secret".to_string(),
            region: "us-east-1".to_string(),
        })
        .unwrap();

    let (session_id, csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state.clone());

    let create_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/buckets/{}/replication", bucket_name),
            &session_id,
            &csrf,
            "action=create&target_connection_id=conn-1&target_bucket=remote-bucket&replication_mode=all&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(create_resp.status(), StatusCode::OK);
    let create_body: Value =
        serde_json::from_slice(&create_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(create_body["action"], "create");
    assert_eq!(create_body["mode"], "all");
    assert_eq!(create_body["enabled"], true);
    let rule = state.replication.get_rule(bucket_name).unwrap();
    assert_eq!(rule.target_connection_id, "conn-1");
    assert_eq!(rule.target_bucket, "remote-bucket");
    assert!(rule.enabled);
    assert_eq!(rule.mode, "all");

    let pause_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/buckets/{}/replication", bucket_name),
            &session_id,
            &csrf,
            "action=pause&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(pause_resp.status(), StatusCode::OK);
    assert!(!state.replication.get_rule(bucket_name).unwrap().enabled);

    let resume_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/buckets/{}/replication", bucket_name),
            &session_id,
            &csrf,
            "action=resume&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(resume_resp.status(), StatusCode::OK);
    assert!(state.replication.get_rule(bucket_name).unwrap().enabled);

    let delete_resp = app
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/buckets/{}/replication", bucket_name),
            &session_id,
            &csrf,
            "action=delete&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(delete_resp.status(), StatusCode::OK);
    assert!(state.replication.get_rule(bucket_name).is_none());
}

#[tokio::test]
async fn test_ui_iam_user_actions_use_real_user_ids() {
    let (state, _tmp) = test_ui_state();
    let (session_id, csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state.clone());

    let iam_page = app
        .clone()
        .oneshot(ui_request(Method::GET, "/ui/iam", &session_id, None))
        .await
        .unwrap();
    assert_eq!(iam_page.status(), StatusCode::OK);
    let iam_page_body = String::from_utf8(
        iam_page
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(iam_page_body.contains("/ui/iam/users/u-test1234"));
    assert!(!iam_page_body.contains("{user_id}"));

    let update_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            "/ui/iam/users/u-test1234",
            &session_id,
            &csrf,
            "display_name=Updated+Admin&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(update_resp.status(), StatusCode::OK);
    let update_body: Value =
        serde_json::from_slice(&update_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(update_body["display_name"], "Updated Admin");

    let policies_json = serde_json::json!([
        {"bucket": "reports", "actions": ["list", "read"], "prefix": "*"}
    ]);
    let policies_encoded = percent_encoding::utf8_percent_encode(
        &policies_json.to_string(),
        percent_encoding::NON_ALPHANUMERIC,
    )
    .to_string();
    let policies_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            "/ui/iam/users/u-test1234/policies",
            &session_id,
            &csrf,
            &format!("policies={}&csrf_token=test", policies_encoded),
        ))
        .await
        .unwrap();
    assert_eq!(policies_resp.status(), StatusCode::OK);
    let policies_body: Value = serde_json::from_slice(
        &policies_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes(),
    )
    .unwrap();
    assert_eq!(policies_body["policies"][0]["bucket"], "reports");

    let create_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            "/ui/iam/users",
            &session_id,
            &csrf,
            "display_name=Alice&access_key=ALICEKEY123&secret_key=alice-secret&csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(create_resp.status(), StatusCode::OK);
    let create_body: Value =
        serde_json::from_slice(&create_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    let created_user_id = create_body["user_id"].as_str().unwrap().to_string();

    let delete_resp = app
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/iam/users/{}/delete", created_user_id),
            &session_id,
            &csrf,
            "csrf_token=test",
        ))
        .await
        .unwrap();
    assert_eq!(delete_resp.status(), StatusCode::OK);
    assert!(state.iam.get_user(&created_user_id).await.is_none());
}

#[tokio::test]
async fn test_ui_bucket_panels_and_history_endpoints_round_trip() {
    let (state, _tmp) = test_ui_state();
    let bucket_name = "ui-bucket";
    state.storage.create_bucket(bucket_name).await.unwrap();

    let (session_id, csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state.clone());

    let policy_json = serde_json::json!({
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": {"AWS": "*"},
            "Action": ["s3:GetObject"],
            "Resource": [format!("arn:aws:s3:::{}/*", bucket_name)]
        }]
    });
    let policy_encoded = percent_encoding::utf8_percent_encode(
        &policy_json.to_string(),
        percent_encoding::NON_ALPHANUMERIC,
    )
    .to_string();
    let policy_resp = app
        .clone()
        .oneshot(ui_form_request(
            Method::POST,
            &format!("/ui/buckets/{}/policy", bucket_name),
            &session_id,
            &csrf,
            &format!(
                "mode=upsert&policy_document={}&csrf_token=test",
                policy_encoded
            ),
        ))
        .await
        .unwrap();
    assert_eq!(policy_resp.status(), StatusCode::OK);
    let policy_body: Value =
        serde_json::from_slice(&policy_resp.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert_eq!(policy_body["ok"], true);
    let bucket_config = state.storage.get_bucket_config(bucket_name).await.unwrap();
    assert_eq!(bucket_config.policy.unwrap(), policy_json);

    let cors_resp = app
        .clone()
        .oneshot(ui_json_request(
            Method::POST,
            &format!("/ui/buckets/{}/cors", bucket_name),
            &session_id,
            &csrf,
            r#"{"rules":[{"AllowedOrigins":["https://example.com"],"AllowedMethods":["GET","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":600}]}"#,
        ))
        .await
        .unwrap();
    assert_eq!(cors_resp.status(), StatusCode::OK);
    let cors_get = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}/cors", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(cors_get.status(), StatusCode::OK);
    let cors_body: Value =
        serde_json::from_slice(&cors_get.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(cors_body["rules"].as_array().unwrap().len(), 1);

    let lifecycle_resp = app
        .clone()
        .oneshot(ui_json_request(
            Method::POST,
            &format!("/ui/buckets/{}/lifecycle", bucket_name),
            &session_id,
            &csrf,
            r#"{"rules":[{"ID":"expire-logs","Status":"Enabled","Filter":{"Prefix":"logs/"},"Expiration":{"Days":30}}]}"#,
        ))
        .await
        .unwrap();
    assert_eq!(lifecycle_resp.status(), StatusCode::OK);
    let lifecycle_get = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}/lifecycle", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(lifecycle_get.status(), StatusCode::OK);
    let lifecycle_body: Value = serde_json::from_slice(
        &lifecycle_get
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes(),
    )
    .unwrap();
    assert_eq!(lifecycle_body["rules"].as_array().unwrap().len(), 1);

    let gc_history = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            "/ui/system/gc/history",
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(gc_history.status(), StatusCode::OK);
    let gc_body: Value =
        serde_json::from_slice(&gc_history.into_body().collect().await.unwrap().to_bytes())
            .unwrap();
    assert!(gc_body["executions"].is_array());

    let integrity_history = app
        .oneshot(ui_request(
            Method::GET,
            "/ui/system/integrity/history",
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(integrity_history.status(), StatusCode::OK);
    let integrity_body: Value = serde_json::from_slice(
        &integrity_history
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes(),
    )
    .unwrap();
    assert!(integrity_body["executions"].is_array());
}

#[tokio::test]
async fn test_ui_bucket_policy_preset_reflects_private_and_public_states() {
    let (state, _tmp) = test_ui_state();
    let bucket_name = "preset-bucket";
    state.storage.create_bucket(bucket_name).await.unwrap();

    let (session_id, _csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state.clone());

    let private_resp = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}?tab=permissions", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(private_resp.status(), StatusCode::OK);
    let private_html = String::from_utf8(
        private_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(private_html.contains(
        "id=\"policyPreset\" name=\"preset\" value=\"private\" data-default=\"private\""
    ));

    let public_policy = serde_json::json!({
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "AllowList",
                "Effect": "Allow",
                "Principal": "*",
                "Action": ["s3:ListBucket"],
                "Resource": [format!("arn:aws:s3:::{}", bucket_name)],
            },
            {
                "Sid": "AllowRead",
                "Effect": "Allow",
                "Principal": "*",
                "Action": ["s3:GetObject"],
                "Resource": [format!("arn:aws:s3:::{}/*", bucket_name)],
            }
        ]
    });
    let mut config = state.storage.get_bucket_config(bucket_name).await.unwrap();
    config.policy = Some(public_policy);
    state
        .storage
        .set_bucket_config(bucket_name, &config)
        .await
        .unwrap();

    let public_resp = app
        .clone()
        .oneshot(ui_request(
            Method::GET,
            &format!("/ui/buckets/{}?tab=permissions", bucket_name),
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(public_resp.status(), StatusCode::OK);
    let public_html = String::from_utf8(
        public_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(public_html
        .contains("id=\"policyPreset\" name=\"preset\" value=\"public\" data-default=\"public\""));
    assert!(public_html.contains("Public Read"));

    let overview_resp = app
        .oneshot(ui_request(Method::GET, "/ui/buckets", &session_id, None))
        .await
        .unwrap();
    assert_eq!(overview_resp.status(), StatusCode::OK);
    let overview_html = String::from_utf8(
        overview_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(overview_html.contains("Public Read"));
}

#[tokio::test]
async fn test_ui_metrics_history_endpoint_reads_system_history() {
    let tmp = tempfile::TempDir::new().unwrap();
    let config_root = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&config_root).unwrap();
    std::fs::write(
        config_root.join("iam.json"),
        serde_json::json!({
            "version": 2,
            "users": [{
                "user_id": "u-test1234",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": TEST_ACCESS_KEY,
                    "secret_key": TEST_SECRET_KEY,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            }]
        })
        .to_string(),
    )
    .unwrap();
    std::fs::write(
        config_root.join("metrics_history.json"),
        serde_json::json!({
            "history": [{
                "timestamp": chrono::Utc::now().to_rfc3339(),
                "cpu_percent": 12.5,
                "memory_percent": 33.3,
                "disk_percent": 44.4,
                "storage_bytes": 1024
            }]
        })
        .to_string(),
    )
    .unwrap();

    let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: config_root.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: true,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 1,
        replication_read_timeout_secs: 1,
        replication_max_retries: 1,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: true,
        templates_dir: manifest_dir.join("templates"),
        static_dir: manifest_dir.join("static"),
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);
    let (session_id, _csrf) = authenticated_ui_session(&state);
    let app = myfsio_server::create_ui_router(state);

    let resp = app
        .oneshot(ui_request(
            Method::GET,
            "/ui/metrics/history?hours=24",
            &session_id,
            None,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(body["enabled"], true);
    assert_eq!(body["history"].as_array().unwrap().len(), 1);
}

#[tokio::test]
async fn test_unauthenticated_request_rejected() {
    let (app, _tmp) = test_app();
    let resp = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Code>AccessDenied</Code>"));
    assert!(body.contains("<Message>Missing credentials</Message>"));
    assert!(body.contains("<Resource>/</Resource>"));
    assert!(body.contains("<RequestId>"));
    assert!(!body.contains("<RequestId></RequestId>"));
}

#[tokio::test]
async fn test_unauthenticated_request_includes_requested_resource_path() {
    let (app, _tmp) = test_app();
    let resp = app
        .oneshot(Request::builder().uri("/ui/").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Code>AccessDenied</Code>"));
    assert!(body.contains("<Message>Missing credentials</Message>"));
    assert!(body.contains("<Resource>/ui/</Resource>"));
    assert!(body.contains("<RequestId>"));
    assert!(!body.contains("<RequestId></RequestId>"));
}

#[tokio::test]
async fn test_list_buckets_empty() {
    let (app, _tmp) = test_app();
    let resp = app
        .oneshot(signed_request(Method::GET, "/", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("ListAllMyBucketsResult"));
}

#[tokio::test]
async fn test_create_and_list_bucket() {
    let (app, _tmp) = test_app();

    let resp = app
        .clone()
        .oneshot(signed_request(Method::PUT, "/test-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .oneshot(signed_request(Method::GET, "/", Body::empty()))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Name>test-bucket</Name>"));
}

#[tokio::test]
async fn test_head_bucket() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/my-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(Method::HEAD, "/my-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers().get("x-amz-bucket-region").unwrap(),
        "us-east-1"
    );

    let resp = app
        .oneshot(signed_request(Method::HEAD, "/nonexistent", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_delete_bucket() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/del-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(Method::DELETE, "/del-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = app
        .oneshot(signed_request(Method::HEAD, "/del-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_put_and_get_object() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/data-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/data-bucket/hello.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "text/plain")
                .body(Body::from("Hello, World!"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert!(resp.headers().get("etag").is_some());

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/data-bucket/hello.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("content-type").unwrap(), "text/plain");
    assert_eq!(resp.headers().get("content-length").unwrap(), "13");
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"Hello, World!");
}

#[tokio::test]
async fn test_content_type_falls_back_to_extension() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/img-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/img-bucket/yum.jpg")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(vec![0_u8, 1, 2, 3, 4]))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/img-bucket/yum.jpg",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("content-type").unwrap(), "image/jpeg");

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/img-bucket/yum.jpg",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("content-type").unwrap(), "image/jpeg");
}

#[tokio::test]
async fn test_head_object() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/hd-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/hd-bucket/file.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(vec![0u8; 256]))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/hd-bucket/file.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("content-length").unwrap(), "256");
    assert!(resp.headers().get("etag").is_some());
    assert!(resp.headers().get("last-modified").is_some());

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/hd-bucket/nonexistent.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_delete_object() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/rm-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/rm-bucket/removeme.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("bye"))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/rm-bucket/removeme.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/rm-bucket/removeme.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_list_objects_v2() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/list-bucket", Body::empty()))
        .await
        .unwrap();

    for name in ["a.txt", "b.txt", "dir/c.txt"] {
        app.clone()
            .oneshot(
                Request::builder()
                    .method(Method::PUT)
                    .uri(format!("/list-bucket/{}", name))
                    .header("x-access-key", TEST_ACCESS_KEY)
                    .header("x-secret-key", TEST_SECRET_KEY)
                    .body(Body::from("data"))
                    .unwrap(),
            )
            .await
            .unwrap();
    }

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/list-bucket?list-type=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Key>a.txt</Key>"));
    assert!(body.contains("<Key>b.txt</Key>"));
    assert!(body.contains("<Key>dir/c.txt</Key>"));
    assert!(body.contains("<KeyCount>3</KeyCount>"));

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/list-bucket?list-type=2&delimiter=/",
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Key>a.txt</Key>"));
    assert!(body.contains("<Key>b.txt</Key>"));
    assert!(body.contains("<Prefix>dir/</Prefix>"));

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/list-bucket?list-type=2&prefix=dir/",
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Key>dir/c.txt</Key>"));
    assert!(!body.contains("<Key>a.txt</Key>"));
}

#[tokio::test]
async fn test_get_nonexistent_object_returns_404() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/err-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/err-bucket/nope.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Code>NoSuchKey</Code>"));
}

#[tokio::test]
async fn test_create_duplicate_bucket_returns_409() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/dup-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(Method::PUT, "/dup-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn test_delete_nonempty_bucket_returns_409() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/full-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/full-bucket/obj.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("data"))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/full-bucket",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn test_object_with_user_metadata() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/meta-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/meta-bucket/tagged.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-meta-author", "test-user")
                .header("x-amz-meta-version", "42")
                .body(Body::from("content"))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/meta-bucket/tagged.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers().get("x-amz-meta-author").unwrap(),
        "test-user"
    );
    assert_eq!(resp.headers().get("x-amz-meta-version").unwrap(), "42");
}

#[tokio::test]
async fn test_wrong_credentials_rejected() {
    let (app, _tmp) = test_app();
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header("x-access-key", "WRONGKEY")
                .header("x-secret-key", "WRONGSECRET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn test_server_header_present() {
    let (app, _tmp) = test_app();
    let resp = app
        .oneshot(signed_request(Method::GET, "/", Body::empty()))
        .await
        .unwrap();
    let server = resp.headers().get("server").unwrap().to_str().unwrap();
    assert!(server.starts_with("MyFSIO-Rust/"));
}

#[tokio::test]
async fn test_range_request() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/range-bucket", Body::empty()))
        .await
        .unwrap();

    let data = "Hello, World! This is range test data.";
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/range-bucket/range.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(data))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/range-bucket/range.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("range", "bytes=0-4")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "5");
    assert!(resp
        .headers()
        .get("content-range")
        .unwrap()
        .to_str()
        .unwrap()
        .starts_with("bytes 0-4/"));
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"Hello");

    let resp = app
        .oneshot(
            Request::builder()
                .uri("/range-bucket/range.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("range", "bytes=-5")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"data.");
}

#[tokio::test]
async fn test_range_get_omits_whole_object_checksum() {
    use base64::engine::general_purpose::STANDARD as B64;
    use sha2::{Digest, Sha256};

    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/csum-bucket", Body::empty()))
        .await
        .unwrap();

    let data = b"Hello, World! This is range checksum data.";
    let sha = B64.encode(Sha256::digest(data));

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/csum-bucket/obj.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-checksum-sha256", &sha)
                .body(Body::from(&data[..]))
                .unwrap(),
        )
        .await
        .unwrap();

    let full = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/csum-bucket/obj.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(full.status(), StatusCode::OK);
    assert_eq!(
        full.headers().get("x-amz-checksum-sha256").unwrap(),
        sha.as_str()
    );

    let partial = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/csum-bucket/obj.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("range", "bytes=0-4")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(partial.status(), StatusCode::PARTIAL_CONTENT);
    for algo in ["sha256", "sha1", "crc32", "crc32c", "crc64nvme"] {
        let header = format!("x-amz-checksum-{}", algo);
        assert!(
            partial.headers().get(&header).is_none(),
            "ranged GET must not include {}",
            header
        );
    }

    let body_bytes = partial.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body_bytes[..], &data[..5]);

    let full_range = format!("bytes=0-{}", data.len() - 1);
    let covering = app
        .oneshot(
            Request::builder()
                .uri("/csum-bucket/obj.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("range", &full_range)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(covering.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(
        covering.headers().get("x-amz-checksum-sha256").unwrap(),
        sha.as_str()
    );
}

#[tokio::test]
async fn test_copy_object() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/src-bucket", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(Method::PUT, "/dst-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/src-bucket/original.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("copy me"))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/dst-bucket/copied.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/src-bucket/original.txt")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("CopyObjectResult"));

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/dst-bucket/copied.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"copy me");
}

#[tokio::test]
async fn test_multipart_upload_http() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/mp-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            "/mp-bucket/big-file.bin?uploads",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("InitiateMultipartUploadResult"));
    assert!(body.contains("<Key>big-file.bin</Key>"));

    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap();

    let part1_data = vec![b'A'; 1024];
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/mp-bucket/big-file.bin?uploadId={}&partNumber=1",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(part1_data))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let etag1 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let part2_data = vec![b'B'; 512];
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/mp-bucket/big-file.bin?uploadId={}&partNumber=2",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(part2_data))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let etag2 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let complete_xml = format!(
        "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"{etag1}\"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>\"{etag2}\"</ETag></Part></CompleteMultipartUpload>"
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(format!("/mp-bucket/big-file.bin?uploadId={}", upload_id))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(complete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("CompleteMultipartUploadResult"));

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/mp-bucket/big-file.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("content-length").unwrap(), "1536");
}

#[tokio::test]
async fn test_delete_objects_batch() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/batch-bucket", Body::empty()))
        .await
        .unwrap();

    for name in ["a.txt", "b.txt", "c.txt"] {
        app.clone()
            .oneshot(
                Request::builder()
                    .method(Method::PUT)
                    .uri(format!("/batch-bucket/{}", name))
                    .header("x-access-key", TEST_ACCESS_KEY)
                    .header("x-secret-key", TEST_SECRET_KEY)
                    .body(Body::from("data"))
                    .unwrap(),
            )
            .await
            .unwrap();
    }

    let delete_xml =
        r#"<Delete><Object><Key>a.txt</Key></Object><Object><Key>b.txt</Key></Object></Delete>"#;

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/batch-bucket?delete")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(delete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("DeleteResult"));
    assert!(body.contains("<Key>a.txt</Key>"));
    assert!(body.contains("<Key>b.txt</Key>"));

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/batch-bucket/a.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);

    let resp = app
        .oneshot(signed_request(
            Method::HEAD,
            "/batch-bucket/c.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_bucket_versioning() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/ver-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/ver-bucket?versioning",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("VersioningConfiguration"));
    assert!(!body.contains("<Status>Enabled</Status>"));
    assert!(!body.contains("<Status>Suspended</Status>"));

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/ver-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/ver-bucket?versioning",
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Status>Suspended</Status>"));

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/ver-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/ver-bucket?versioning",
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Status>Enabled</Status>"));
}

#[tokio::test]
async fn test_versioned_object_can_be_read_and_deleted_by_version_id() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/versions-bucket",
            Body::empty(),
        ))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/versions-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/versions-bucket/doc.txt",
            Body::from("first"),
        ))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/versions-bucket/doc.txt",
            Body::from("second"),
        ))
        .await
        .unwrap();

    let list_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/versions-bucket?versions",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(list_resp.status(), StatusCode::OK);
    let list_body = String::from_utf8(
        list_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let archived_version_id = list_body
        .split("<Version>")
        .skip(1)
        .find(|block| block.contains("<IsLatest>false</IsLatest>"))
        .and_then(|block| {
            block
                .split("<VersionId>")
                .nth(1)
                .and_then(|s| s.split_once("</VersionId>").map(|(id, _)| id))
        })
        .filter(|id| *id != "null")
        .expect("archived version id")
        .to_string();

    let version_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            &format!("/versions-bucket/doc.txt?versionId={}", archived_version_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(version_resp.status(), StatusCode::OK);
    assert_eq!(
        version_resp.headers()["x-amz-version-id"].to_str().unwrap(),
        archived_version_id
    );
    let version_body = version_resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&version_body[..], b"first");

    let traversal_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            &format!(
                "/versions-bucket/doc.txt?versionId=../other/{}",
                archived_version_id
            ),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(traversal_resp.status(), StatusCode::NOT_FOUND);

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/versions-bucket/doc.txt",
            Body::from("third"),
        ))
        .await
        .unwrap();
    let limited_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/versions-bucket?versions&max-keys=1",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(limited_resp.status(), StatusCode::OK);
    let limited_body = String::from_utf8(
        limited_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert_eq!(limited_body.matches("<Version>").count(), 1);
    assert!(limited_body.contains("<IsTruncated>true</IsTruncated>"));

    let delete_resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            &format!("/versions-bucket/doc.txt?versionId={}", archived_version_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);

    let missing_resp = app
        .oneshot(signed_request(
            Method::GET,
            &format!("/versions-bucket/doc.txt?versionId={}", archived_version_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(missing_resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_versioned_put_and_delete_emit_version_headers_and_delete_markers() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/compat-bucket", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/compat-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let put_resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/compat-bucket/doc.txt",
            Body::from("first"),
        ))
        .await
        .unwrap();
    assert_eq!(put_resp.status(), StatusCode::OK);
    let first_version = put_resp
        .headers()
        .get("x-amz-version-id")
        .expect("PUT on versioned bucket must emit x-amz-version-id")
        .to_str()
        .unwrap()
        .to_string();
    assert!(!first_version.is_empty());

    let overwrite_resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/compat-bucket/doc.txt",
            Body::from("second"),
        ))
        .await
        .unwrap();
    assert_eq!(overwrite_resp.status(), StatusCode::OK);
    let second_version = overwrite_resp
        .headers()
        .get("x-amz-version-id")
        .expect("overwrite on versioned bucket must emit a new x-amz-version-id")
        .to_str()
        .unwrap()
        .to_string();
    assert_ne!(first_version, second_version);

    let delete_resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/compat-bucket/doc.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
    assert_eq!(
        delete_resp
            .headers()
            .get("x-amz-delete-marker")
            .and_then(|v| v.to_str().ok()),
        Some("true")
    );
    assert!(delete_resp.headers().contains_key("x-amz-version-id"));

    let versions_resp = app
        .oneshot(signed_request(
            Method::GET,
            "/compat-bucket?versions",
            Body::empty(),
        ))
        .await
        .unwrap();
    let versions_body = String::from_utf8(
        versions_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(
        versions_body.contains("<DeleteMarker>"),
        "expected DeleteMarker entry in ListObjectVersions output, got: {}",
        versions_body
    );
}

#[tokio::test]
async fn test_consecutive_slashes_in_key_round_trip() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/slashes-bucket",
            Body::empty(),
        ))
        .await
        .unwrap();

    let put_ab = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/slashes-bucket/a/b",
            Body::from("single"),
        ))
        .await
        .unwrap();
    assert_eq!(put_ab.status(), StatusCode::OK);

    let put_double = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/slashes-bucket/a//b",
            Body::from("double"),
        ))
        .await
        .unwrap();
    assert_eq!(put_double.status(), StatusCode::OK);

    let put_triple = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/slashes-bucket/a///b",
            Body::from("triple"),
        ))
        .await
        .unwrap();
    assert_eq!(put_triple.status(), StatusCode::OK);

    let get_ab = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/slashes-bucket/a/b",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(get_ab.status(), StatusCode::OK);
    let body_ab = get_ab.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body_ab[..], b"single");

    let get_triple = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/slashes-bucket/a///b",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(get_triple.status(), StatusCode::OK);
    let body_triple = get_triple.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body_triple[..], b"triple");

    let list_resp = app
        .oneshot(signed_request(
            Method::GET,
            "/slashes-bucket?list-type=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    let list_body = String::from_utf8(
        list_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(
        list_body.contains("<Key>a/b</Key>"),
        "expected a/b in listing: {}",
        list_body
    );
    assert!(
        list_body.contains("<Key>a//b</Key>"),
        "expected a//b in listing: {}",
        list_body
    );
    assert!(
        list_body.contains("<Key>a///b</Key>"),
        "expected a///b in listing: {}",
        list_body
    );
}

#[tokio::test]
async fn test_delete_live_version_restores_previous_to_live_slot() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/restore-bucket",
            Body::empty(),
        ))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/restore-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let v1_resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/restore-bucket/k",
            Body::from("one"),
        ))
        .await
        .unwrap();
    let v1 = v1_resp
        .headers()
        .get("x-amz-version-id")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let v2_resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/restore-bucket/k",
            Body::from("two"),
        ))
        .await
        .unwrap();
    let v2 = v2_resp
        .headers()
        .get("x-amz-version-id")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();
    assert_ne!(v1, v2);

    let del = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            &format!("/restore-bucket/k?versionId={}", v2),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(del.status(), StatusCode::NO_CONTENT);

    let get_live = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/restore-bucket/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(get_live.status(), StatusCode::OK);
    let body = get_live.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"one");

    let get_v1 = app
        .oneshot(signed_request(
            Method::GET,
            &format!("/restore-bucket/k?versionId={}", v1),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(get_v1.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_delete_active_delete_marker_restores_previous_to_live_slot() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/undel-bucket", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/undel-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/undel-bucket/k",
            Body::from("only"),
        ))
        .await
        .unwrap();

    let del = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/undel-bucket/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    let dm_version = del
        .headers()
        .get("x-amz-version-id")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();
    assert_eq!(
        del.headers()
            .get("x-amz-delete-marker")
            .and_then(|v| v.to_str().ok()),
        Some("true")
    );

    let shadowed = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/undel-bucket/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(shadowed.status(), StatusCode::NOT_FOUND);

    let del_dm = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            &format!("/undel-bucket/k?versionId={}", dm_version),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(del_dm.status(), StatusCode::NO_CONTENT);

    let restored = app
        .oneshot(signed_request(
            Method::GET,
            "/undel-bucket/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(restored.status(), StatusCode::OK);
    let body = restored.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"only");
}

#[tokio::test]
async fn test_versioned_get_on_delete_marker_returns_method_not_allowed() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/dm-bucket", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/dm-bucket?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/dm-bucket/k", Body::from("x")))
        .await
        .unwrap();

    let del = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/dm-bucket/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    let dm_version = del
        .headers()
        .get("x-amz-version-id")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let versioned = app
        .oneshot(signed_request(
            Method::GET,
            &format!("/dm-bucket/k?versionId={}", dm_version),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(versioned.status(), StatusCode::METHOD_NOT_ALLOWED);
}

#[tokio::test]
async fn test_retention_is_enforced_when_deleting_archived_version() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/locked-versions",
            Body::empty(),
        ))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/locked-versions?versioning")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/locked-versions/doc.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-object-lock-mode", "GOVERNANCE")
                .header(
                    "x-amz-object-lock-retain-until-date",
                    "2099-01-01T00:00:00Z",
                )
                .body(Body::from("locked"))
                .unwrap(),
        )
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/locked-versions/doc.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-bypass-governance-retention", "true")
                .body(Body::from("replacement"))
                .unwrap(),
        )
        .await
        .unwrap();

    let list_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/locked-versions?versions",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(list_resp.status(), StatusCode::OK);
    let list_body = String::from_utf8(
        list_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let archived_version_id = list_body
        .split("<Version>")
        .skip(1)
        .find(|block| block.contains("<IsLatest>false</IsLatest>"))
        .and_then(|block| {
            block
                .split("<VersionId>")
                .nth(1)
                .and_then(|s| s.split_once("</VersionId>").map(|(id, _)| id))
        })
        .filter(|id| *id != "null")
        .expect("archived version id")
        .to_string();

    let denied = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            &format!("/locked-versions/doc.txt?versionId={}", archived_version_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(denied.status(), StatusCode::FORBIDDEN);

    let allowed = app
        .oneshot(
            Request::builder()
                .method(Method::DELETE)
                .uri(format!(
                    "/locked-versions/doc.txt?versionId={}",
                    archived_version_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-bypass-governance-retention", "true")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(allowed.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_put_object_validates_content_md5() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/md5-bucket", Body::empty()))
        .await
        .unwrap();

    let bad_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/md5-bucket/object.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-md5", "AAAAAAAAAAAAAAAAAAAAAA==")
                .body(Body::from("hello"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(bad_resp.status(), StatusCode::BAD_REQUEST);
    let bad_body = String::from_utf8(
        bad_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(bad_body.contains("<Code>BadDigest</Code>"));

    let good_resp = app
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/md5-bucket/object.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-md5", "XUFAKrxLKna5cZ2REBfFkg==")
                .body(Body::from("hello"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(good_resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_x_amz_content_sha256_mismatch_returns_bad_digest() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/sha256-bucket", Body::empty()))
        .await
        .unwrap();

    let bad_resp = app
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/sha256-bucket/object.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header(
                    "x-amz-content-sha256",
                    "0000000000000000000000000000000000000000000000000000000000000000",
                )
                .body(Body::from("hello"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(bad_resp.status(), StatusCode::BAD_REQUEST);
    let bad_body = String::from_utf8(
        bad_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(bad_body.contains("<Code>BadDigest</Code>"));
    assert!(bad_body.contains("x-amz-content-sha256"));
}

#[tokio::test]
async fn test_max_keys_zero_respects_marker_and_v2_cursors() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/cursor-bucket", Body::empty()))
        .await
        .unwrap();
    for key in ["a.txt", "b.txt"] {
        app.clone()
            .oneshot(signed_request(
                Method::PUT,
                &format!("/cursor-bucket/{}", key),
                Body::from(key.to_string()),
            ))
            .await
            .unwrap();
    }

    let marker_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/cursor-bucket?max-keys=0&marker=b.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    let marker_body = String::from_utf8(
        marker_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(marker_body.contains("<IsTruncated>false</IsTruncated>"));

    let start_after_resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/cursor-bucket?list-type=2&max-keys=0&start-after=b.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    let start_after_body = String::from_utf8(
        start_after_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(start_after_body.contains("<IsTruncated>false</IsTruncated>"));

    let token = URL_SAFE.encode("b.txt");
    let token_resp = app
        .oneshot(signed_request(
            Method::GET,
            &format!(
                "/cursor-bucket?list-type=2&max-keys=0&continuation-token={}",
                token
            ),
            Body::empty(),
        ))
        .await
        .unwrap();
    let token_body = String::from_utf8(
        token_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(token_body.contains("<IsTruncated>false</IsTruncated>"));
}

#[tokio::test]
async fn test_put_object_tagging_and_standard_headers_are_persisted() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/headers-bucket",
            Body::empty(),
        ))
        .await
        .unwrap();

    let put_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/headers-bucket/report.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-tagging", "env=prod&name=quarter%201")
                .header("cache-control", "max-age=60")
                .header("content-disposition", "attachment")
                .header("content-language", "en-US")
                .header("x-amz-storage-class", "STANDARD_IA")
                .body(Body::from("report"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(put_resp.status(), StatusCode::OK);

    let head_resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/headers-bucket/report.txt",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(head_resp.status(), StatusCode::OK);
    assert_eq!(head_resp.headers()["cache-control"], "max-age=60");
    assert_eq!(head_resp.headers()["content-disposition"], "attachment");
    assert_eq!(head_resp.headers()["content-language"], "en-US");
    assert_eq!(head_resp.headers()["x-amz-storage-class"], "STANDARD_IA");

    let tags_resp = app
        .oneshot(signed_request(
            Method::GET,
            "/headers-bucket/report.txt?tagging",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(tags_resp.status(), StatusCode::OK);
    let tags_body = String::from_utf8(
        tags_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(tags_body.contains("<Key>env</Key>"));
    assert!(tags_body.contains("<Value>prod</Value>"));
    assert!(tags_body.contains("<Key>name</Key>"));
    assert!(tags_body.contains("<Value>quarter 1</Value>"));
}

#[tokio::test]
async fn test_virtual_host_bucket_routes_to_s3_object_handlers() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/vh-bucket", Body::empty()))
        .await
        .unwrap();

    let put_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/hello.txt")
                .header("host", "vh-bucket.localhost")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("virtual host body"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(put_resp.status(), StatusCode::OK);

    let get_resp = app
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/hello.txt")
                .header("host", "vh-bucket.localhost")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(get_resp.status(), StatusCode::OK);
    let body = get_resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"virtual host body");
}

#[tokio::test]
async fn test_bucket_tagging() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/tag-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/tag-bucket?tagging")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/tag-bucket?tagging",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Key>env</Key>"));
    assert!(body.contains("<Value>prod</Value>"));

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/tag-bucket?tagging",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_bucket_location() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/loc-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/loc-bucket?location",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("LocationConstraint"));
    assert!(body.contains("us-east-1"));
}

#[tokio::test]
async fn test_bucket_cors() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/cors-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/cors-bucket?cors",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/cors-bucket?cors")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>"))
                .unwrap(),
        )
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/cors-bucket?cors",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/cors-bucket?cors",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_bucket_acl() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/acl-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            "/acl-bucket?acl",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("AccessControlPolicy"));
    assert!(body.contains("FULL_CONTROL"));
}

#[tokio::test]
async fn test_object_tagging() {
    let (app, _tmp) = test_app();
    let app = app.into_service();

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::PUT, "/tag-bucket", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(
            Method::PUT,
            "/tag-bucket/myfile.txt",
            Body::from("file content"),
        ),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<TagSet>"));
    assert!(body.contains("</TagSet>"));

    let tag_xml =
        r#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#;
    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(
            Method::PUT,
            "/tag-bucket/myfile.txt?tagging",
            Body::from(tag_xml),
        ),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Key>env</Key>"));
    assert!(body.contains("<Value>prod</Value>"));

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(
            Method::DELETE,
            "/tag-bucket/myfile.txt?tagging",
            Body::empty(),
        ),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(!body.contains("<Key>env</Key>"));
}

#[tokio::test]
async fn test_object_acl() {
    let (app, _tmp) = test_app();
    let app = app.into_service();

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::PUT, "/acl-obj-bucket", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(
            Method::PUT,
            "/acl-obj-bucket/myfile.txt",
            Body::from("content"),
        ),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/acl-obj-bucket/myfile.txt?acl", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("AccessControlPolicy"));
    assert!(body.contains("FULL_CONTROL"));
}

#[tokio::test]
async fn test_object_legal_hold() {
    let (app, _tmp) = test_app();
    let app = app.into_service();

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::PUT, "/lh-bucket", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::PUT, "/lh-bucket/obj.txt", Body::from("data")),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/lh-bucket/obj.txt?legal-hold", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Status>OFF</Status>"));
}

#[tokio::test]
async fn test_list_objects_v1_marker_flow() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/v1-bucket", Body::empty()))
        .await
        .unwrap();

    for name in ["a.txt", "b.txt", "c.txt"] {
        app.clone()
            .oneshot(
                Request::builder()
                    .method(Method::PUT)
                    .uri(format!("/v1-bucket/{}", name))
                    .header("x-access-key", TEST_ACCESS_KEY)
                    .header("x-secret-key", TEST_SECRET_KEY)
                    .body(Body::from("data"))
                    .unwrap(),
            )
            .await
            .unwrap();
    }

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/v1-bucket?max-keys=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Marker></Marker>"));
    assert!(
        body.contains("<IsTruncated>true</IsTruncated>")
            || body.contains("<IsTruncated>false</IsTruncated>")
    );
}

#[tokio::test]
async fn test_bucket_quota_roundtrip() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/quota-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/quota-bucket?quota")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/json")
                .body(Body::from(r#"{"max_size_bytes": 1024, "max_objects": 10}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/quota-bucket?quota",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(body["quota"]["max_size_bytes"], 1024);
    assert_eq!(body["quota"]["max_objects"], 10);

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/quota-bucket?quota",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_bucket_policy_and_status_roundtrip() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/policy-bucket", Body::empty()))
        .await
        .unwrap();

    let policy = r#"{
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": "*",
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::policy-bucket/*"
      }]
    }"#;

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/policy-bucket?policy")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/json")
                .body(Body::from(policy))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/policy-bucket?policy",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert!(body.get("Statement").is_some());

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/policy-bucket?policyStatus",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<IsPublic>TRUE</IsPublic>"));

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/policy-bucket?policy",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_public_bucket_policy_allows_anonymous_reads() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/public-bucket", Body::empty()))
        .await
        .unwrap();

    let put_object = Request::builder()
        .method(Method::PUT)
        .uri("/public-bucket/hello.txt")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::from("hello world"))
        .unwrap();
    let put_resp = app.clone().oneshot(put_object).await.unwrap();
    assert_eq!(put_resp.status(), StatusCode::OK);

    let policy = r#"{
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": "*",
          "Action": "s3:ListBucket",
          "Resource": "arn:aws:s3:::public-bucket"
        },
        {
          "Effect": "Allow",
          "Principal": "*",
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::public-bucket/*"
        }
      ]
    }"#;

    let policy_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/public-bucket?policy")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/json")
                .body(Body::from(policy))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(policy_resp.status(), StatusCode::NO_CONTENT);

    let object_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/public-bucket/hello.txt")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(object_resp.status(), StatusCode::OK);
    let object_body = object_resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(object_body.as_ref(), b"hello world");

    let list_resp = app
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/public-bucket")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(list_resp.status(), StatusCode::OK);
    let list_body = String::from_utf8(
        list_resp
            .into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(list_body.contains("hello.txt"));
}

#[tokio::test]
async fn test_bucket_root_with_trailing_slash_works() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/slash-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .oneshot(signed_request(Method::GET, "/slash-bucket/", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_bucket_replication_roundtrip() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/repl-bucket", Body::empty()))
        .await
        .unwrap();

    let repl_xml = "<ReplicationConfiguration><Role>arn:aws:iam::123456789012:role/s3-repl</Role><Rule><ID>rule-1</ID></Rule></ReplicationConfiguration>";

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/repl-bucket?replication")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/xml")
                .body(Body::from(repl_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/repl-bucket?replication",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("ReplicationConfiguration"));

    let resp = app
        .oneshot(signed_request(
            Method::DELETE,
            "/repl-bucket?replication",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_list_parts_via_get_upload_id() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/parts-bucket", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            "/parts-bucket/large.bin?uploads",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/parts-bucket/large.bin?uploadId={}&partNumber=1",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(vec![1_u8, 2, 3, 4]))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .oneshot(signed_request(
            Method::GET,
            &format!("/parts-bucket/large.bin?uploadId={}", upload_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("ListPartsResult"));
    assert!(body.contains("<PartNumber>1</PartNumber>"));
}

#[tokio::test]
async fn test_conditional_get_and_head() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/cond-bucket", Body::empty()))
        .await
        .unwrap();

    let put_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/cond-bucket/item.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("abc"))
                .unwrap(),
        )
        .await
        .unwrap();
    let etag = put_resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/cond-bucket/item.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("if-none-match", etag.as_str())
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/cond-bucket/item.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("if-match", "\"does-not-match\"")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);

    let resp = app
        .oneshot(
            Request::builder()
                .method(Method::HEAD)
                .uri("/cond-bucket/item.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("if-none-match", etag.as_str())
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
}

#[tokio::test]
async fn test_copy_source_preconditions() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/src-pre", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(Method::PUT, "/dst-pre", Body::empty()))
        .await
        .unwrap();

    let put_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/src-pre/original.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("copy source"))
                .unwrap(),
        )
        .await
        .unwrap();
    let etag = put_resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/dst-pre/copied.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/src-pre/original.txt")
                .header("x-amz-copy-source-if-match", "\"bad-etag\"")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);

    let resp = app
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/dst-pre/copied.txt")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/src-pre/original.txt")
                .header("x-amz-copy-source-if-match", etag.as_str())
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_select_object_content_csv_to_json_events() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/sel-bucket", Body::empty()))
        .await
        .unwrap();

    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/sel-bucket/people.csv")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "text/csv")
                .body(Body::from("name,age\nalice,30\nbob,40\n"))
                .unwrap(),
        )
        .await
        .unwrap();

    let select_xml = r#"
<SelectObjectContentRequest>
  <Expression>SELECT name, age FROM S3Object WHERE CAST(age AS INTEGER) &gt;= 35</Expression>
  <ExpressionType>SQL</ExpressionType>
  <InputSerialization>
    <CSV>
      <FileHeaderInfo>USE</FileHeaderInfo>
    </CSV>
  </InputSerialization>
  <OutputSerialization>
    <JSON>
      <RecordDelimiter>\n</RecordDelimiter>
    </JSON>
  </OutputSerialization>
</SelectObjectContentRequest>
"#;

    let resp = app
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/sel-bucket/people.csv?select&select-type=2")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/xml")
                .body(Body::from(select_xml))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers().get("content-type").unwrap(),
        "application/octet-stream"
    );
    assert_eq!(
        resp.headers().get("x-amz-request-charged").unwrap(),
        "requester"
    );

    let body = resp.into_body().collect().await.unwrap().to_bytes();
    let events = parse_select_events(&body);
    assert!(events.iter().any(|(name, _)| name == "Records"));
    assert!(events.iter().any(|(name, _)| name == "Stats"));
    assert!(events.iter().any(|(name, _)| name == "End"));

    let mut records = String::new();
    for (name, payload) in events {
        if name == "Records" {
            records.push_str(&String::from_utf8_lossy(&payload));
        }
    }
    assert!(records.contains("bob"));
    assert!(!records.contains("alice"));
}

#[tokio::test]
async fn test_select_object_content_requires_expression() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/sel-missing-exp",
            Body::empty(),
        ))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/sel-missing-exp/file.csv")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("a,b\n1,2\n"))
                .unwrap(),
        )
        .await
        .unwrap();

    let select_xml = r#"
<SelectObjectContentRequest>
  <ExpressionType>SQL</ExpressionType>
  <InputSerialization><CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV></InputSerialization>
  <OutputSerialization><CSV /></OutputSerialization>
</SelectObjectContentRequest>
"#;

    let resp = app
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/sel-missing-exp/file.csv?select")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/xml")
                .body(Body::from(select_xml))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Code>InvalidRequest</Code>"));
    assert!(body.contains("Expression is required"));
}

#[tokio::test]
async fn test_select_object_content_rejects_non_xml_content_type() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/sel-ct", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/sel-ct/file.csv")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("a,b\n1,2\n"))
                .unwrap(),
        )
        .await
        .unwrap();

    let select_xml = r#"
<SelectObjectContentRequest>
  <Expression>SELECT * FROM S3Object</Expression>
  <ExpressionType>SQL</ExpressionType>
  <InputSerialization><CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV></InputSerialization>
  <OutputSerialization><CSV /></OutputSerialization>
</SelectObjectContentRequest>
"#;

    let resp = app
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/sel-ct/file.csv?select")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("content-type", "application/json")
                .body(Body::from(select_xml))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("<Code>InvalidRequest</Code>"));
    assert!(body.contains("Content-Type must be application/xml or text/xml"));
}

#[tokio::test]
async fn test_static_website_serves_configured_error_document() {
    let (app, _tmp) = test_website_app(Some("404.html")).await;

    let resp = app
        .oneshot(website_request(Method::GET, "/missing.html"))
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    assert!(resp
        .headers()
        .get("content-type")
        .unwrap()
        .to_str()
        .unwrap()
        .starts_with("text/html"));
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(body.contains("Bucket Not Found Page"));
}

#[tokio::test]
async fn test_static_website_default_404_returns_html_body() {
    let (app, _tmp) = test_website_app(None).await;

    let resp = app
        .clone()
        .oneshot(website_request(Method::GET, "/missing.html"))
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    assert!(resp
        .headers()
        .get("content-type")
        .unwrap()
        .to_str()
        .unwrap()
        .starts_with("text/html"));
    let content_length = resp
        .headers()
        .get("content-length")
        .unwrap()
        .to_str()
        .unwrap()
        .parse::<usize>()
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert_eq!(body.len(), content_length);
    assert_eq!(body, "<h1>404 page not found</h1>");

    let head_resp = app
        .oneshot(website_request(Method::HEAD, "/missing.html"))
        .await
        .unwrap();
    assert_eq!(head_resp.status(), StatusCode::NOT_FOUND);
    let head_content_length = head_resp
        .headers()
        .get("content-length")
        .unwrap()
        .to_str()
        .unwrap()
        .parse::<usize>()
        .unwrap();
    let head_body = head_resp
        .into_body()
        .collect()
        .await
        .unwrap()
        .to_bytes()
        .to_vec();
    assert_eq!(head_content_length, content_length);
    assert!(head_body.is_empty());
}

#[tokio::test]
async fn test_non_admin_authorization_enforced() {
    let iam_json = serde_json::json!({
        "version": 2,
        "users": [{
            "user_id": "u-limited",
            "display_name": "limited",
            "enabled": true,
            "access_keys": [{
                "access_key": TEST_ACCESS_KEY,
                "secret_key": TEST_SECRET_KEY,
                "status": "active"
            }],
            "policies": [{
                "bucket": "authz-bucket",
                "actions": ["list", "read"],
                "prefix": "*"
            }]
        }]
    });

    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();
    std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);
    state.storage.create_bucket("authz-bucket").await.unwrap();
    let app = myfsio_server::create_router(state);

    let resp = app
        .clone()
        .oneshot(signed_request(Method::PUT, "/denied-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);

    let resp = app
        .oneshot(signed_request(Method::GET, "/authz-bucket", Body::empty()))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

async fn test_app_encrypted() -> (axum::Router, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();

    let iam_json = serde_json::json!({
        "version": 2,
        "users": [{
            "user_id": "u-test1234",
            "display_name": "admin",
            "enabled": true,
            "access_keys": [{
                "access_key": TEST_ACCESS_KEY,
                "secret_key": TEST_SECRET_KEY,
                "status": "active"
            }],
            "policies": [{
                "bucket": "*",
                "actions": ["*"],
                "prefix": "*"
            }]
        }]
    });
    std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: true,
        kms_enabled: true,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new_with_encryption(config).await;
    let app = myfsio_server::create_router(state);
    (app, tmp)
}

#[tokio::test]
async fn test_sse_s3_encrypt_decrypt_roundtrip() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::PUT, "/enc-bucket", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let plaintext = "This is secret data that should be encrypted at rest!";
    let req = Request::builder()
        .method(Method::PUT)
        .uri("/enc-bucket/secret.txt")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .header("x-amz-server-side-encryption", "AES256")
        .header("content-type", "text/plain")
        .body(Body::from(plaintext))
        .unwrap();

    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers().get("x-amz-server-side-encryption").unwrap(),
        "AES256"
    );

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/enc-bucket/secret.txt", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers().get("x-amz-server-side-encryption").unwrap(),
        "AES256"
    );
    let body = resp
        .into_body()
        .collect()
        .await
        .unwrap()
        .to_bytes()
        .to_vec();
    assert_eq!(String::from_utf8(body).unwrap(), plaintext);
}

#[tokio::test]
async fn test_kms_key_crud() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();

    let req = Request::builder()
        .method(Method::POST)
        .uri("/kms/keys")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .header("content-type", "application/json")
        .body(Body::from(r#"{"Description": "test key"}"#))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let key_id = body["KeyId"].as_str().unwrap().to_string();
    assert!(!key_id.is_empty());

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, "/kms/keys", Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(body["keys"].as_array().unwrap().len(), 1);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(Method::GET, &format!("/kms/keys/{}", key_id), Body::empty()),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = tower::ServiceExt::oneshot(
        app.clone(),
        signed_request(
            Method::DELETE,
            &format!("/kms/keys/{}", key_id),
            Body::empty(),
        ),
    )
    .await
    .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_kms_encrypt_decrypt() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();

    let req = Request::builder()
        .method(Method::POST)
        .uri("/kms/keys")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::from(r#"{"Description": "enc key"}"#))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let key_id = body["KeyId"].as_str().unwrap().to_string();

    use base64::engine::general_purpose::STANDARD as B64;
    use base64::Engine;

    let plaintext = b"Hello KMS!";
    let enc_req = serde_json::json!({
        "KeyId": key_id,
        "Plaintext": B64.encode(plaintext),
    });
    let req = Request::builder()
        .method(Method::POST)
        .uri("/kms/encrypt")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::from(enc_req.to_string()))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let ct_b64 = body["CiphertextBlob"].as_str().unwrap().to_string();

    let dec_req = serde_json::json!({
        "KeyId": key_id,
        "CiphertextBlob": ct_b64,
    });
    let req = Request::builder()
        .method(Method::POST)
        .uri("/kms/decrypt")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::from(dec_req.to_string()))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let pt_b64 = body["Plaintext"].as_str().unwrap();
    let result = B64.decode(pt_b64).unwrap();
    assert_eq!(result, plaintext);
}

fn deterministic_payload(len: usize) -> Vec<u8> {
    (0..len)
        .map(|i| ((i * 2654435761usize) >> 16) as u8)
        .collect()
}

async fn put_sse_s3(
    app: &axum::routing::RouterIntoService<Body>,
    bucket: &str,
    key: &str,
    body: Vec<u8>,
) {
    let req = Request::builder()
        .method(Method::PUT)
        .uri(format!("/{}", bucket))
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::empty())
        .unwrap();
    let _ = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();

    let req = Request::builder()
        .method(Method::PUT)
        .uri(format!("/{}/{}", bucket, key))
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .header("x-amz-server-side-encryption", "AES256")
        .header("content-type", "application/octet-stream")
        .body(Body::from(body))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

async fn range_get(
    app: &axum::routing::RouterIntoService<Body>,
    uri: &str,
    range: &str,
    extra_headers: &[(&str, &str)],
) -> axum::http::Response<Body> {
    let mut builder = Request::builder()
        .method(Method::GET)
        .uri(uri)
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .header("range", range);
    for (k, v) in extra_headers {
        builder = builder.header(*k, *v);
    }
    tower::ServiceExt::oneshot(app.clone(), builder.body(Body::empty()).unwrap())
        .await
        .unwrap()
}

async fn body_bytes(resp: axum::http::Response<Body>) -> Vec<u8> {
    resp.into_body()
        .collect()
        .await
        .unwrap()
        .to_bytes()
        .to_vec()
}

#[tokio::test]
async fn test_sse_s3_range_get_multi_chunk() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let payload = deterministic_payload(200_000);
    put_sse_s3(&app, "rng-mc", "obj.bin", payload.clone()).await;

    let resp = range_get(&app, "/rng-mc/obj.bin", "bytes=60000-140000", &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "80001");
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 60000-140000/200000"
    );
    assert_eq!(
        resp.headers().get("x-amz-server-side-encryption").unwrap(),
        "AES256"
    );
    assert_eq!(body_bytes(resp).await, payload[60000..=140000]);
}

#[tokio::test]
async fn test_sse_s3_range_get_within_single_chunk() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let payload = deterministic_payload(200_000);
    put_sse_s3(&app, "rng-sc", "obj.bin", payload.clone()).await;

    let resp = range_get(&app, "/rng-sc/obj.bin", "bytes=100-4999", &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "4900");
    assert_eq!(body_bytes(resp).await, payload[100..=4999]);
}

#[tokio::test]
async fn test_sse_s3_range_get_suffix() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let payload = deterministic_payload(200_000);
    put_sse_s3(&app, "rng-sx", "obj.bin", payload.clone()).await;

    let resp = range_get(&app, "/rng-sx/obj.bin", "bytes=-1024", &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "1024");
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 198976-199999/200000"
    );
    assert_eq!(body_bytes(resp).await, payload[198_976..]);
}

#[tokio::test]
async fn test_sse_s3_range_get_final_partial_chunk() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let size = 65_536 + 12_345;
    let payload = deterministic_payload(size);
    put_sse_s3(&app, "rng-fp", "obj.bin", payload.clone()).await;

    let last_start = 70_000;
    let last_end = size as u64 - 1;
    let range = format!("bytes={}-{}", last_start, last_end);
    let resp = range_get(&app, "/rng-fp/obj.bin", &range, &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    let expected_len = (last_end - last_start + 1).to_string();
    assert_eq!(
        resp.headers().get("content-length").unwrap(),
        &expected_len.as_str()
    );
    assert_eq!(
        body_bytes(resp).await,
        payload[last_start as usize..=last_end as usize]
    );
}

#[tokio::test]
async fn test_sse_s3_range_get_open_ended() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let payload = deterministic_payload(100_000);
    put_sse_s3(&app, "rng-oe", "obj.bin", payload.clone()).await;

    let resp = range_get(&app, "/rng-oe/obj.bin", "bytes=90000-", &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "10000");
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 90000-99999/100000"
    );
    assert_eq!(body_bytes(resp).await, payload[90_000..]);
}

#[tokio::test]
async fn test_sse_s3_range_unsatisfiable_for_plaintext_size() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let payload = deterministic_payload(10_000);
    put_sse_s3(&app, "rng-un", "obj.bin", payload).await;

    let resp = range_get(&app, "/rng-un/obj.bin", "bytes=20000-30000", &[]).await;
    assert!(
        resp.status() == StatusCode::RANGE_NOT_SATISFIABLE
            || resp.status() == StatusCode::BAD_REQUEST,
        "unexpected status: {}",
        resp.status()
    );
}

#[tokio::test]
async fn test_plaintext_range_still_works() {
    let (app, _tmp) = test_app_encrypted().await;
    let app = app.into_service();
    let req = Request::builder()
        .method(Method::PUT)
        .uri("/plain-rng")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::empty())
        .unwrap();
    let _ = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();

    let payload = deterministic_payload(8_000);
    let req = Request::builder()
        .method(Method::PUT)
        .uri("/plain-rng/obj.bin")
        .header("x-access-key", TEST_ACCESS_KEY)
        .header("x-secret-key", TEST_SECRET_KEY)
        .body(Body::from(payload.clone()))
        .unwrap();
    let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = range_get(&app, "/plain-rng/obj.bin", "bytes=100-199", &[]).await;
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert!(resp.headers().get("x-amz-server-side-encryption").is_none());
    assert_eq!(body_bytes(resp).await, payload[100..=199]);
}

async fn poison_object(state: &myfsio_server::state::AppState, bucket: &str, key: &str) {
    use myfsio_storage::fs_backend::{META_KEY_CORRUPTED, META_KEY_CORRUPTION_DETAIL};
    let mut meta = state
        .storage
        .get_object_metadata(bucket, key)
        .await
        .unwrap();
    meta.insert(META_KEY_CORRUPTED.to_string(), "true".to_string());
    meta.insert(
        META_KEY_CORRUPTION_DETAIL.to_string(),
        "test poisoned for §E recovery".to_string(),
    );
    state
        .storage
        .put_object_metadata(bucket, key, &meta)
        .await
        .unwrap();
}

async fn set_legal_hold(state: &myfsio_server::state::AppState, bucket: &str, key: &str) {
    let mut meta = state
        .storage
        .get_object_metadata(bucket, key)
        .await
        .unwrap();
    meta.insert("__legal_hold__".to_string(), "true".to_string());
    state
        .storage
        .put_object_metadata(bucket, key, &meta)
        .await
        .unwrap();
}

#[tokio::test]
async fn test_poisoned_object_can_be_overwritten_via_put() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/heal-put", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/heal-put/healme",
            Body::from("v1 bytes"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    poison_object(&state, "heal-put", "healme").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/heal-put/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status().as_u16(),
        422,
        "poisoned GET must surface 422 ObjectCorrupted"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/heal-put/healme",
            Body::from("v2 bytes"),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "PUT must overwrite a poisoned object instead of returning 422"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/heal-put/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(body_bytes(resp).await, b"v2 bytes".to_vec());
}

#[tokio::test]
async fn test_poisoned_object_can_be_deleted() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/heal-del", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/heal-del/healme",
            Body::from("rotting"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    poison_object(&state, "heal-del", "healme").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/heal-del/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::NO_CONTENT,
        "DELETE must succeed on a poisoned object instead of returning 422"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/heal-del/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::NOT_FOUND,
        "HEAD after DELETE must be 404, not 422 (poison flag was cleared)"
    );
}

#[tokio::test]
async fn test_poisoned_quarantined_object_can_be_deleted() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/heal-q", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/heal-q/healme",
            Body::from("rotting"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    poison_object(&state, "heal-q", "healme").await;

    let live_path = state
        .storage
        .get_object_path("heal-q", "healme")
        .await
        .unwrap();
    std::fs::remove_file(&live_path).unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/heal-q/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/heal-q/healme",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

async fn complete_two_part_upload(
    app: &axum::Router,
    bucket: &str,
    key: &str,
    part1: Vec<u8>,
    part2: Vec<u8>,
) {
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            &format!("/{}", bucket),
            Body::empty(),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            &format!("/{}/{}?uploads", bucket, key),
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/{}/{}?uploadId={}&partNumber=1",
                    bucket, key, upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(part1))
                .unwrap(),
        )
        .await
        .unwrap();
    let etag1 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/{}/{}?uploadId={}&partNumber=2",
                    bucket, key, upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(part2))
                .unwrap(),
        )
        .await
        .unwrap();
    let etag2 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let complete_xml = format!(
        "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"{etag1}\"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>\"{etag2}\"</ETag></Part></CompleteMultipartUpload>"
    );
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(format!("/{}/{}?uploadId={}", bucket, key, upload_id))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(complete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_part_number_get_returns_only_that_part() {
    let (app, _tmp) = test_app();
    let part1 = vec![b'A'; 1024];
    let part2 = vec![b'B'; 512];
    complete_two_part_upload(&app, "mp-pn", "obj.bin", part1.clone(), part2.clone()).await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/mp-pn/obj.bin?partNumber=1",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(
        resp.headers().get("x-amz-mp-parts-count").unwrap(),
        "2",
        "x-amz-mp-parts-count must reflect the assembled object"
    );
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 0-1023/1536"
    );
    assert_eq!(resp.headers().get("content-length").unwrap(), "1024");
    assert_eq!(body_bytes(resp).await, part1);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/mp-pn/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 1024-1535/1536"
    );
    assert_eq!(resp.headers().get("content-length").unwrap(), "512");
    assert_eq!(body_bytes(resp).await, part2);
}

#[tokio::test]
async fn test_head_part_number_returns_part_size() {
    let (app, _tmp) = test_app();
    let part1 = vec![b'X'; 2048];
    let part2 = vec![b'Y'; 256];
    complete_two_part_upload(&app, "mp-head", "obj.bin", part1, part2).await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/mp-head/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "256");
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 2048-2303/2304"
    );
    assert_eq!(resp.headers().get("x-amz-mp-parts-count").unwrap(), "2");
}

#[tokio::test]
async fn test_part_number_out_of_range_rejected() {
    let (app, _tmp) = test_app();
    complete_two_part_upload(
        &app,
        "mp-oob",
        "obj.bin",
        vec![b'A'; 8],
        vec![b'B'; 8],
    )
    .await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/mp-oob/obj.bin?partNumber=5",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    assert_eq!(
        resp.headers().get("x-amz-error-code").unwrap(),
        "InvalidPart"
    );
}

#[tokio::test]
async fn test_part_number_one_on_non_multipart_returns_full_body() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/single", Body::empty()))
        .await
        .unwrap();

    let payload = b"single-shot upload".to_vec();
    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/single/obj.bin",
            Body::from(payload.clone()),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/single/obj.bin?partNumber=1",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "partNumber=1 on a non-multipart object must return the whole body as 200 OK"
    );
    assert!(resp.headers().get("x-amz-mp-parts-count").is_none());
    assert_eq!(body_bytes(resp).await, payload);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/single/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_legal_hold_blocks_delete_even_when_poisoned() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/lock-poison", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/lock-poison/locked.bin",
            Body::from("v1"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    set_legal_hold(&state, "lock-poison", "locked.bin").await;
    poison_object(&state, "lock-poison", "locked.bin").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/lock-poison/locked.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "legal-hold must still block DELETE on a poisoned object (poison must not bypass object lock)"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/lock-poison/locked.bin",
            Body::from("v2"),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "legal-hold must still block PUT-overwrite on a poisoned object"
    );
}

#[tokio::test]
async fn test_governance_retention_blocks_poisoned_delete_without_bypass() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/gov-poison", Body::empty()))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/gov-poison/locked.bin",
            Body::from("v1"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let mut meta = state
        .storage
        .get_object_metadata("gov-poison", "locked.bin")
        .await
        .unwrap();
    let retain_until = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
    meta.insert(
        "__object_retention__".to_string(),
        format!(
            "{{\"mode\":\"GOVERNANCE\",\"retain_until_date\":\"{}\"}}",
            retain_until
        ),
    );
    state
        .storage
        .put_object_metadata("gov-poison", "locked.bin", &meta)
        .await
        .unwrap();

    poison_object(&state, "gov-poison", "locked.bin").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/gov-poison/locked.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "governance retention must block DELETE without bypass header even when object is poisoned"
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::DELETE)
                .uri("/gov-poison/locked.bin")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-bypass-governance-retention", "true")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::NO_CONTENT,
        "with x-amz-bypass-governance-retention=true, poisoned governance-locked object can be deleted"
    );
}

#[tokio::test]
async fn test_part_number_uses_served_snapshot_metadata() {
    let (app, state, _tmp) = test_app_and_state();
    let part1 = vec![b'A'; 1024];
    let part2 = vec![b'B'; 512];
    complete_two_part_upload(&app, "race-pn", "obj.bin", part1.clone(), part2.clone()).await;

    let live_meta = state
        .storage
        .get_object_metadata("race-pn", "obj.bin")
        .await
        .unwrap();
    let original_part_sizes = live_meta
        .get(myfsio_storage::fs_backend::META_KEY_PART_SIZES)
        .cloned()
        .unwrap();
    assert_eq!(original_part_sizes, "1024,512");

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/race-pn/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(
        resp.headers().get("content-range").unwrap(),
        "bytes 1024-1535/1536",
        "Content-Range must be derived from the snapshot's __part_sizes__"
    );
    assert_eq!(resp.headers().get("x-amz-mp-parts-count").unwrap(), "2");
    assert_eq!(body_bytes(resp).await, part2);
}

#[tokio::test]
async fn test_bulk_delete_poisoned_unlocked_object_succeeds() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/bulk-poison",
            Body::empty(),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/bulk-poison/dead.bin",
            Body::from("rotting"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    poison_object(&state, "bulk-poison", "dead.bin").await;

    let body = "<Delete><Object><Key>dead.bin</Key></Object></Delete>";
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/bulk-poison?delete")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body_str = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(
        body_str.contains("<Deleted>") && body_str.contains("<Key>dead.bin</Key>"),
        "bulk delete on poisoned-unlocked object should report success, got: {}",
        body_str
    );
    assert!(
        !body_str.contains("ObjectCorrupted"),
        "bulk delete must not surface ObjectCorrupted on a poisoned-unlocked object: {}",
        body_str
    );
}

#[tokio::test]
async fn test_bulk_delete_poisoned_locked_object_blocked_by_legal_hold() {
    let (app, state, _tmp) = test_app_and_state();

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/bulk-locked",
            Body::empty(),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/bulk-locked/locked.bin",
            Body::from("rotting"),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    set_legal_hold(&state, "bulk-locked", "locked.bin").await;
    poison_object(&state, "bulk-locked", "locked.bin").await;

    let body = "<Delete><Object><Key>locked.bin</Key></Object></Delete>";
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/bulk-locked?delete")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body_str = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(
        body_str.contains("<Error>") && body_str.contains("AccessDenied"),
        "bulk delete on legal-hold-locked poisoned object must report AccessDenied, got: {}",
        body_str
    );
    assert!(
        !body_str.contains("ObjectCorrupted"),
        "bulk delete must not surface ObjectCorrupted on a poisoned-locked object: {}",
        body_str
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/bulk-locked/locked.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status().as_u16(),
        422,
        "object must still be poisoned after blocked bulk-delete"
    );
}

async fn upload_zero_length_final_part_object(
    app: &axum::Router,
    bucket: &str,
    key: &str,
    part1: Vec<u8>,
) {
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            &format!("/{}", bucket),
            Body::empty(),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            &format!("/{}/{}?uploads", bucket, key),
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/{}/{}?uploadId={}&partNumber=1",
                    bucket, key, upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(part1))
                .unwrap(),
        )
        .await
        .unwrap();
    let etag1 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/{}/{}?uploadId={}&partNumber=2",
                    bucket, key, upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let etag2 = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let complete_xml = format!(
        "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"{etag1}\"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>\"{etag2}\"</ETag></Part></CompleteMultipartUpload>"
    );
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(format!("/{}/{}?uploadId={}", bucket, key, upload_id))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(complete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_zero_length_part_get_omits_content_range() {
    let (app, _tmp) = test_app();
    upload_zero_length_final_part_object(&app, "zero-pn", "obj.bin", vec![b'A'; 1024]).await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/zero-pn/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "0");
    assert!(
        resp.headers().get("content-range").is_none(),
        "zero-length part GET must not emit Content-Range (would be misleading)"
    );
    assert_eq!(resp.headers().get("x-amz-mp-parts-count").unwrap(), "2");
    assert_eq!(body_bytes(resp).await, Vec::<u8>::new());
}

#[tokio::test]
async fn test_zero_length_part_head_omits_content_range() {
    let (app, _tmp) = test_app();
    upload_zero_length_final_part_object(&app, "zero-pn-h", "obj.bin", vec![b'A'; 1024]).await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/zero-pn-h/obj.bin?partNumber=2",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(resp.headers().get("content-length").unwrap(), "0");
    assert!(
        resp.headers().get("content-range").is_none(),
        "zero-length part HEAD must not emit Content-Range"
    );
    assert_eq!(resp.headers().get("x-amz-mp-parts-count").unwrap(), "2");
}

#[tokio::test]
async fn test_zero_length_part_get_evaluates_if_none_match() {
    let (app, _tmp) = test_app();
    upload_zero_length_final_part_object(&app, "zero-pn-cond", "obj.bin", vec![b'A'; 1024]).await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/zero-pn-cond/obj.bin",
            Body::empty(),
        ))
        .await
        .unwrap();
    let etag = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/zero-pn-cond/obj.bin?partNumber=2")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("if-none-match", etag.clone())
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::NOT_MODIFIED,
        "zero-length part GET must honor If-None-Match like any other GET"
    );
}

async fn enable_versioning(app: &axum::Router, bucket: &str) {
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!("/{}?versioning", bucket))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(resp.status().is_success());
}

async fn suspend_versioning(app: &axum::Router, bucket: &str) {
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!("/{}?versioning", bucket))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(
                    "<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(resp.status().is_success());
}

async fn list_versions_xml(app: &axum::Router, bucket: &str) -> String {
    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            &format!("/{}?versions", bucket),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap()
}

#[tokio::test]
async fn test_delete_pre_versioning_null_version_single_object() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-del-1", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-1/pre.txt",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-del-1").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-del-1/pre.txt?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert!(
        resp.headers().get("x-amz-delete-marker").is_none(),
        "deleting an explicit null version must not produce a delete-marker"
    );

    let body = list_versions_xml(&app, "null-del-1").await;
    assert!(
        !body.contains("<Version>") && !body.contains("<DeleteMarker>"),
        "no Version or DeleteMarker entries should remain after null delete, got: {}",
        body
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-del-1",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::NO_CONTENT,
        "DeleteBucket must succeed once the null version is gone"
    );
}

#[tokio::test]
async fn test_delete_pre_versioning_null_version_batch() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-del-batch", Body::empty()))
        .await
        .unwrap();
    for name in ["pre1", "pre2"] {
        app.clone()
            .oneshot(signed_request(
                Method::PUT,
                &format!("/null-del-batch/{}", name),
                Body::from("legacy"),
            ))
            .await
            .unwrap();
    }

    enable_versioning(&app, "null-del-batch").await;

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-batch/post",
            Body::from("v1"),
        ))
        .await
        .unwrap();

    let list_body = list_versions_xml(&app, "null-del-batch").await;
    let post_version_id = list_body
        .split("<Version>")
        .skip(1)
        .find(|block| block.contains("<Key>post</Key>"))
        .and_then(|block| {
            block
                .split("<VersionId>")
                .nth(1)
                .and_then(|s| s.split_once("</VersionId>").map(|(id, _)| id))
        })
        .filter(|id| *id != "null")
        .expect("post object should have a real version id")
        .to_string();

    let delete_xml = format!(
        "<Delete>\
         <Object><Key>pre1</Key><VersionId>null</VersionId></Object>\
         <Object><Key>pre2</Key><VersionId>null</VersionId></Object>\
         <Object><Key>post</Key><VersionId>{}</VersionId></Object>\
         </Delete>",
        post_version_id
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/null-del-batch?delete")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(delete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();

    assert!(
        !body.contains("<DeleteMarker>true</DeleteMarker>"),
        "batch delete of explicit null versions must not produce delete-markers, got: {}",
        body
    );
    assert!(
        !body.contains("<Error>"),
        "batch delete should not have errored on null versions, got: {}",
        body
    );

    let remaining = list_versions_xml(&app, "null-del-batch").await;
    assert!(
        !remaining.contains("<Version>") && !remaining.contains("<DeleteMarker>"),
        "all entries should be gone after batch null delete, got: {}",
        remaining
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-del-batch",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_delete_suspended_versioning_null_version() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-del-susp", Body::empty()))
        .await
        .unwrap();

    enable_versioning(&app, "null-del-susp").await;

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-susp/k",
            Body::from("v1"),
        ))
        .await
        .unwrap();

    suspend_versioning(&app, "null-del-susp").await;

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-susp/k",
            Body::from("null-overwrite"),
        ))
        .await
        .unwrap();

    let before = list_versions_xml(&app, "null-del-susp").await;
    assert!(
        before.contains("<VersionId>null</VersionId>"),
        "expected a null-version entry while suspended, got: {}",
        before
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-del-susp/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert!(
        resp.headers().get("x-amz-delete-marker").is_none(),
        "deleting the null version must not create a delete-marker"
    );

    let after = list_versions_xml(&app, "null-del-susp").await;
    assert!(
        !after.contains("<VersionId>null</VersionId>"),
        "null version should be gone, got: {}",
        after
    );
    assert!(
        after.contains("<Version>"),
        "the prior real version should still be listed, got: {}",
        after
    );
}

#[tokio::test]
async fn test_pre_versioning_object_archived_as_null_on_overwrite() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-archive", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-archive/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-archive").await;

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-archive/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let body = list_versions_xml(&app, "null-archive").await;
    assert!(
        body.contains("<VersionId>null</VersionId>"),
        "pre-versioning data must be archived under VersionId=null after overwrite, got: {}",
        body
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-archive/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "the archived null version must be readable by versionId=null"
    );
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-archive/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert!(
        resp.headers().get("x-amz-delete-marker").is_none(),
        "deleting an archived null version must not produce a delete-marker"
    );

    let after = list_versions_xml(&app, "null-archive").await;
    assert!(
        !after.contains("<VersionId>null</VersionId>"),
        "null version should be gone after delete, got: {}",
        after
    );
    assert!(
        after.contains("<Version>"),
        "the real version should still be listed, got: {}",
        after
    );
}

#[tokio::test]
async fn test_pre_versioning_soft_delete_archives_under_null() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-soft-del", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-soft-del/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-soft-del").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-soft-del/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert_eq!(
        resp.headers()
            .get("x-amz-delete-marker")
            .and_then(|v| v.to_str().ok()),
        Some("true"),
        "soft-delete on a versioning-enabled bucket must produce a delete-marker"
    );

    let body = list_versions_xml(&app, "null-soft-del").await;
    assert!(
        body.contains("<VersionId>null</VersionId>"),
        "pre-versioning data should be archived as VersionId=null on soft-delete, got: {}",
        body
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-soft-del/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

#[tokio::test]
async fn test_delete_null_version_with_real_version_kept() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-del-mix", Body::empty()))
        .await
        .unwrap();

    enable_versioning(&app, "null-del-mix").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-mix/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    suspend_versioning(&app, "null-del-mix").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-del-mix/k",
            Body::from("null-on-top"),
        ))
        .await
        .unwrap();

    let list_body = list_versions_xml(&app, "null-del-mix").await;
    assert!(
        list_body.contains("<VersionId>null</VersionId>"),
        "expected coexisting null + real versions, got: {}",
        list_body
    );
    let real_version_id = list_body
        .split("<Version>")
        .skip(1)
        .find(|block| {
            block.contains("<Key>k</Key>")
                && !block.contains("<VersionId>null</VersionId>")
        })
        .and_then(|block| {
            block
                .split("<VersionId>")
                .nth(1)
                .and_then(|s| s.split_once("</VersionId>").map(|(id, _)| id))
        })
        .expect("expected a real archived version id")
        .to_string();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-del-mix/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert!(
        resp.headers().get("x-amz-delete-marker").is_none(),
        "deleting the explicit null version must not produce a delete-marker"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            &format!("/null-del-mix/k?versionId={}", real_version_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "real version must remain after null delete"
    );
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"real-v1");

    let after = list_versions_xml(&app, "null-del-mix").await;
    assert!(
        !after.contains("<VersionId>null</VersionId>"),
        "null version should be gone, got: {}",
        after
    );
}

#[tokio::test]
async fn test_batch_delete_null_version_respects_archived_legal_hold() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-locked", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-locked/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-locked").await;

    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-locked/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = tmp
        .path()
        .join(".myfsio.sys")
        .join("buckets")
        .join("null-locked")
        .join("versions")
        .join("k")
        .join("null.json");
    assert!(
        null_manifest.is_file(),
        "expected archived null manifest at {:?}",
        null_manifest
    );
    let mut record: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&null_manifest).unwrap()).unwrap();
    record
        .get_mut("metadata")
        .and_then(|m| m.as_object_mut())
        .unwrap()
        .insert(
            "__legal_hold__".to_string(),
            serde_json::Value::String("ON".to_string()),
        );
    std::fs::write(&null_manifest, serde_json::to_string(&record).unwrap()).unwrap();

    let delete_xml = "<Delete>\
         <Object><Key>k</Key><VersionId>null</VersionId></Object>\
         </Delete>";

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/null-locked?delete")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(delete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    assert!(
        body.contains("<Error>") && body.contains("<Code>AccessDenied</Code>"),
        "batch delete of legal-hold-protected archived null version must be denied, got: {}",
        body
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-locked/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "the protected archived null version must still exist"
    );
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

async fn write_archived_null_lock(
    tmp: &tempfile::TempDir,
    bucket: &str,
    key: &str,
    lock_metadata: serde_json::Value,
) -> std::path::PathBuf {
    let null_manifest = tmp
        .path()
        .join(".myfsio.sys")
        .join("buckets")
        .join(bucket)
        .join("versions")
        .join(key)
        .join("null.json");
    let mut record: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&null_manifest).unwrap()).unwrap();
    let metadata_obj = record
        .get_mut("metadata")
        .and_then(|m| m.as_object_mut())
        .unwrap();
    if let Some(obj) = lock_metadata.as_object() {
        for (k, v) in obj {
            metadata_obj.insert(k.clone(), v.clone());
        }
    }
    std::fs::write(&null_manifest, serde_json::to_string(&record).unwrap()).unwrap();
    null_manifest
}

#[tokio::test]
async fn test_suspended_put_refuses_to_purge_legal_held_archived_null() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-protected", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-protected/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-protected").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-protected/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-protected",
        "k",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&app, "null-protected").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-protected/k",
            Body::from("would-purge-locked-null"),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "suspended PUT must be denied while a legal-held archived null version exists"
    );

    assert!(
        null_manifest.is_file(),
        "the locked archived null manifest must remain intact"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-protected/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

#[tokio::test]
async fn test_suspended_put_governance_bypass_allows_archived_null_purge() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-gov", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-gov/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-gov").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-gov/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let retain_until = (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339();
    let retention_json = serde_json::json!({
        "mode": "GOVERNANCE",
        "retain_until_date": retain_until,
    })
    .to_string();
    write_archived_null_lock(
        &tmp,
        "null-gov",
        "k",
        serde_json::json!({ "__object_retention__": retention_json }),
    )
    .await;

    suspend_versioning(&app, "null-gov").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-gov/k",
            Body::from("blocked-without-bypass"),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "suspended PUT must be denied while GOVERNANCE retention is active without bypass"
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/null-gov/k")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-bypass-governance-retention", "true")
                .body(Body::from("allowed-with-bypass"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(
        resp.status().is_success(),
        "suspended PUT with x-amz-bypass-governance-retention: true must succeed, got {}",
        resp.status()
    );

    let after = list_versions_xml(&app, "null-gov").await;
    assert_eq!(
        after.matches("<VersionId>null</VersionId>").count(),
        1,
        "exactly one null version must remain after governance-bypass purge, got: {}",
        after
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-gov/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"allowed-with-bypass",
        "the null version must now be the new bypass-governance write"
    );
}

#[tokio::test]
async fn test_multipart_complete_respects_archived_null_legal_hold() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-mp", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-mp/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-mp").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-mp/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-mp",
        "k",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&app, "null-mp").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            "/null-mp/k?uploads",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!("/null-mp/k?uploadId={}&partNumber=1", upload_id))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("part1-bytes"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let etag = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let complete_xml = format!(
        "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"{}\"</ETag></Part></CompleteMultipartUpload>",
        etag
    );
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(format!("/null-mp/k?uploadId={}", upload_id))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(complete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "CompleteMultipartUpload must be denied while a legal-held archived null version exists"
    );

    assert!(
        null_manifest.is_file(),
        "the locked archived null manifest must remain intact after the blocked complete"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-mp/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

#[tokio::test]
async fn test_form_post_respects_archived_null_legal_hold() {
    use base64::engine::general_purpose::STANDARD as B64;

    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-form", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-form/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-form").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-form/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-form",
        "k",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&app, "null-form").await;

    let policy_json = r#"{"expiration":"2099-01-01T00:00:00Z"}"#;
    let policy_b64 = B64.encode(policy_json.as_bytes());

    let date_stamp = "20260426";
    let region = "us-east-1";
    let service = "s3";
    let credential = format!(
        "{}/{}/{}/{}/aws4_request",
        TEST_ACCESS_KEY, date_stamp, region, service
    );
    let signing_key =
        myfsio_auth::sigv4::derive_signing_key(TEST_SECRET_KEY, date_stamp, region, service);
    let signature =
        myfsio_auth::sigv4::compute_post_policy_signature(&signing_key, &policy_b64);

    let boundary = "----TestFormBoundary";
    let body = format!(
        "--{boundary}\r\n\
Content-Disposition: form-data; name=\"key\"\r\n\r\n\
k\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"policy\"\r\n\r\n\
{policy}\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"x-amz-credential\"\r\n\r\n\
{credential}\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"x-amz-algorithm\"\r\n\r\n\
AWS4-HMAC-SHA256\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"x-amz-date\"\r\n\r\n\
{date}T000000Z\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"x-amz-signature\"\r\n\r\n\
{signature}\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"file\"; filename=\"k\"\r\n\
Content-Type: application/octet-stream\r\n\r\n\
form-bypass-attempt\r\n\
--{boundary}--\r\n",
        boundary = boundary,
        policy = policy_b64,
        credential = credential,
        date = date_stamp,
        signature = signature,
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/null-form")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header(
                    "content-type",
                    format!("multipart/form-data; boundary={}", boundary),
                )
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "form POST upload must be denied while a legal-held archived null version exists"
    );

    assert!(
        null_manifest.is_file(),
        "the locked archived null manifest must remain intact after the blocked form POST"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-form/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

async fn ui_post_json(
    uri: &str,
    session_id: &str,
    csrf: &str,
    json_body: &str,
) -> Request<Body> {
    Request::builder()
        .method(Method::POST)
        .uri(uri)
        .header(
            "cookie",
            format!(
                "{}={}",
                myfsio_server::session::SESSION_COOKIE_NAME,
                session_id
            ),
        )
        .header(myfsio_server::session::CSRF_HEADER_NAME, csrf)
        .header("content-type", "application/json")
        .body(Body::from(json_body.to_string()))
        .unwrap()
}

#[tokio::test]
async fn test_ui_copy_respects_archived_null_legal_hold() {
    let (s3_app, state, tmp) = test_app_and_state();

    s3_app
        .clone()
        .oneshot(signed_request(Method::PUT, "/null-ui-cp", Body::empty()))
        .await
        .unwrap();
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-cp/dst",
            Body::from("legacy"),
        ))
        .await
        .unwrap();
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-cp/src",
            Body::from("source-bytes"),
        ))
        .await
        .unwrap();

    enable_versioning(&s3_app, "null-ui-cp").await;
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-cp/dst",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-ui-cp",
        "dst",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&s3_app, "null-ui-cp").await;

    let (session_id, csrf) = authenticated_ui_session(&state);
    let ui_app = myfsio_server::create_ui_router(state.clone());

    let resp = ui_app
        .oneshot(
            ui_post_json(
                "/ui/buckets/null-ui-cp/objects/src/copy",
                &session_id,
                &csrf,
                r#"{"dest_bucket":"null-ui-cp","dest_key":"dst"}"#,
            )
            .await,
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "UI copy onto a key with a legal-held archived null version must be denied"
    );

    assert!(null_manifest.is_file());

    let resp = s3_app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-ui-cp/dst?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

#[tokio::test]
async fn test_ui_move_respects_archived_null_legal_hold() {
    let (s3_app, state, tmp) = test_app_and_state();

    s3_app
        .clone()
        .oneshot(signed_request(Method::PUT, "/null-ui-mv", Body::empty()))
        .await
        .unwrap();
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-mv/dst",
            Body::from("legacy"),
        ))
        .await
        .unwrap();
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-mv/src",
            Body::from("source-bytes"),
        ))
        .await
        .unwrap();

    enable_versioning(&s3_app, "null-ui-mv").await;
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-mv/dst",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-ui-mv",
        "dst",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&s3_app, "null-ui-mv").await;

    let (session_id, csrf) = authenticated_ui_session(&state);
    let ui_app = myfsio_server::create_ui_router(state.clone());

    let resp = ui_app
        .oneshot(
            ui_post_json(
                "/ui/buckets/null-ui-mv/objects/src/move",
                &session_id,
                &csrf,
                r#"{"dest_bucket":"null-ui-mv","dest_key":"dst"}"#,
            )
            .await,
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "UI move onto a key with a legal-held archived null version must be denied"
    );

    assert!(null_manifest.is_file());

    let resp = s3_app
        .clone()
        .oneshot(signed_request(
            Method::HEAD,
            "/null-ui-mv/src",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "the source object must remain since move was rejected"
    );
}

#[tokio::test]
async fn test_ui_complete_multipart_respects_archived_null_legal_hold() {
    let (s3_app, state, tmp) = test_app_and_state();

    s3_app
        .clone()
        .oneshot(signed_request(Method::PUT, "/null-ui-mp", Body::empty()))
        .await
        .unwrap();
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-mp/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&s3_app, "null-ui-mp").await;
    s3_app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-ui-mp/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = write_archived_null_lock(
        &tmp,
        "null-ui-mp",
        "k",
        serde_json::json!({ "__legal_hold__": "ON" }),
    )
    .await;

    suspend_versioning(&s3_app, "null-ui-mp").await;

    let resp = s3_app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            "/null-ui-mp/k?uploads",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap()
        .to_string();

    let resp = s3_app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/null-ui-mp/k?uploadId={}&partNumber=1",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from("part1"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let etag = resp
        .headers()
        .get("etag")
        .unwrap()
        .to_str()
        .unwrap()
        .trim_matches('"')
        .to_string();

    let (session_id, csrf) = authenticated_ui_session(&state);
    let ui_app = myfsio_server::create_ui_router(state.clone());

    let payload = format!(
        r#"{{"parts":[{{"part_number":1,"etag":"{}"}}]}}"#,
        etag
    );
    let resp = ui_app
        .oneshot(
            ui_post_json(
                &format!(
                    "/ui/buckets/null-ui-mp/multipart/{}/complete",
                    upload_id
                ),
                &session_id,
                &csrf,
                &payload,
            )
            .await,
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "UI CompleteMultipartUpload over a legal-held archived null version must be denied"
    );

    assert!(null_manifest.is_file());

    let resp = s3_app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-ui-mp/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy");
}

#[tokio::test]
async fn test_copy_object_with_version_id_null_copies_archived_null_not_current() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-cp-src", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-cp-dst", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-cp-src/k",
            Body::from("legacy-null-bytes"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-cp-src").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-cp-src/k",
            Body::from("real-v1-bytes"),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/null-cp-dst/copy-of-null")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/null-cp-src/k?versionId=null")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "copy with versionId=null must succeed against the archived null version"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-cp-dst/copy-of-null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"legacy-null-bytes",
        "copy must take the archived null version's bytes, not the current real version"
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/null-cp-dst/copy-of-current")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/null-cp-src/k")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-cp-dst/copy-of-current",
            Body::empty(),
        ))
        .await
        .unwrap();
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"real-v1-bytes",
        "copy without versionId must take the current (real) version"
    );
}

#[tokio::test]
async fn test_copy_object_with_version_id_null_after_soft_delete() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-cp-dm", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-cp-dm-dst", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-cp-dm/k",
            Body::from("legacy-null-bytes"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-cp-dm").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-cp-dm/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert_eq!(
        resp.headers()
            .get("x-amz-delete-marker")
            .and_then(|v| v.to_str().ok()),
        Some("true"),
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/null-cp-dm-dst/recovered")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-copy-source", "/null-cp-dm/k?versionId=null")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "copy of versionId=null must reach the archived null even when a delete-marker hides the current key"
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-cp-dm-dst/recovered",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"legacy-null-bytes");
}

#[tokio::test]
async fn test_archived_null_lock_guard_fails_closed_on_unreadable_manifest() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-failclosed", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-failclosed/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-failclosed").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-failclosed/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let null_manifest = tmp
        .path()
        .join(".myfsio.sys")
        .join("buckets")
        .join("null-failclosed")
        .join("versions")
        .join("k")
        .join("null.json");
    assert!(null_manifest.is_file());
    std::fs::write(&null_manifest, b"{ this is not valid json").unwrap();

    let null_data = tmp
        .path()
        .join(".myfsio.sys")
        .join("buckets")
        .join("null-failclosed")
        .join("versions")
        .join("k")
        .join("null.bin");
    assert!(null_data.is_file());

    suspend_versioning(&app, "null-failclosed").await;

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-failclosed/k",
            Body::from("would-purge"),
        ))
        .await
        .unwrap();
    assert!(
        !resp.status().is_success(),
        "suspended PUT must fail closed when archived null manifest is unreadable, got {}",
        resp.status()
    );

    assert!(
        null_data.is_file(),
        "the archived null data file must remain since the lock-check failed closed"
    );
}

#[tokio::test]
async fn test_upload_part_copy_with_version_id_null() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-mpcp-src", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-mpcp-dst", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-mpcp-src/k",
            Body::from("legacy-null-bytes"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-mpcp-src").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-mpcp-src/k",
            Body::from("real-v1-bytes-different"),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::POST,
            "/null-mpcp-dst/recovered?uploads",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    let upload_id = body
        .split("<UploadId>")
        .nth(1)
        .unwrap()
        .split("</UploadId>")
        .next()
        .unwrap()
        .to_string();

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri(format!(
                    "/null-mpcp-dst/recovered?uploadId={}&partNumber=1",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header(
                    "x-amz-copy-source",
                    "/null-mpcp-src/k?versionId=null",
                )
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "UploadPartCopy with versionId=null must resolve to the archived null version"
    );

    let complete_xml = format!(
        "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>\"{}\"</ETag></Part></CompleteMultipartUpload>",
        // The CopyPartResult ETag is the MD5 of the copied bytes — for "legacy-null-bytes"
        // we don't need to verify the exact value here; we just need the complete to succeed.
        // List the parts to get the etag.
        list_first_part_etag(&app, "null-mpcp-dst", "recovered", &upload_id).await,
    );
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(format!(
                    "/null-mpcp-dst/recovered?uploadId={}",
                    upload_id
                ))
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .body(Body::from(complete_xml))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-mpcp-dst/recovered",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"legacy-null-bytes",
        "multipart copy must take the archived null version's bytes, not the current real version"
    );
}

async fn list_first_part_etag(
    app: &axum::Router,
    bucket: &str,
    key: &str,
    upload_id: &str,
) -> String {
    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            &format!("/{}/{}?uploadId={}", bucket, key, upload_id),
            Body::empty(),
        ))
        .await
        .unwrap();
    let body = String::from_utf8(
        resp.into_body()
            .collect()
            .await
            .unwrap()
            .to_bytes()
            .to_vec(),
    )
    .unwrap();
    body.split("<ETag>")
        .nth(1)
        .unwrap()
        .split("</ETag>")
        .next()
        .unwrap()
        .trim_matches('"')
        .to_string()
}

#[tokio::test]
async fn test_suspended_put_compliance_retention_blocks_even_with_bypass() {
    let (app, tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-comp", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-comp/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-comp").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-comp/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let retain_until = (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339();
    let retention_json = serde_json::json!({
        "mode": "COMPLIANCE",
        "retain_until_date": retain_until,
    })
    .to_string();
    write_archived_null_lock(
        &tmp,
        "null-comp",
        "k",
        serde_json::json!({ "__object_retention__": retention_json }),
    )
    .await;

    suspend_versioning(&app, "null-comp").await;

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::PUT)
                .uri("/null-comp/k")
                .header("x-access-key", TEST_ACCESS_KEY)
                .header("x-secret-key", TEST_SECRET_KEY)
                .header("x-amz-bypass-governance-retention", "true")
                .body(Body::from("still-blocked"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "COMPLIANCE retention must block suspended PUT even with bypass-governance header"
    );
}

#[tokio::test]
async fn test_suspended_put_after_soft_delete_does_not_create_duplicate_null() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-postdm", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-postdm/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-postdm").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-postdm/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::DELETE,
            "/null-postdm/k",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    assert_eq!(
        resp.headers()
            .get("x-amz-delete-marker")
            .and_then(|v| v.to_str().ok()),
        Some("true"),
    );

    suspend_versioning(&app, "null-postdm").await;
    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-postdm/k",
            Body::from("post-dm-null"),
        ))
        .await
        .unwrap();
    assert!(
        resp.status().is_success(),
        "suspended PUT after soft-delete should succeed, got {}",
        resp.status()
    );

    let after = list_versions_xml(&app, "null-postdm").await;
    assert_eq!(
        after.matches("<VersionId>null</VersionId>").count(),
        1,
        "exactly one null version must exist after suspended PUT, got: {}",
        after
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-postdm/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"post-dm-null",
        "GET ?versionId=null must resolve unambiguously to the new suspended write"
    );
}

#[tokio::test]
async fn test_suspended_put_purges_stale_archived_null_version() {
    let (app, _tmp) = test_app();

    app.clone()
        .oneshot(signed_request(Method::PUT, "/null-purge", Body::empty()))
        .await
        .unwrap();
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-purge/k",
            Body::from("legacy"),
        ))
        .await
        .unwrap();

    enable_versioning(&app, "null-purge").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-purge/k",
            Body::from("real-v1"),
        ))
        .await
        .unwrap();

    let mid = list_versions_xml(&app, "null-purge").await;
    assert_eq!(
        mid.matches("<VersionId>null</VersionId>").count(),
        1,
        "after enable+overwrite, exactly one null entry should exist (archived legacy), got: {}",
        mid
    );

    suspend_versioning(&app, "null-purge").await;
    app.clone()
        .oneshot(signed_request(
            Method::PUT,
            "/null-purge/k",
            Body::from("suspended-null"),
        ))
        .await
        .unwrap();

    let after = list_versions_xml(&app, "null-purge").await;
    assert_eq!(
        after.matches("<VersionId>null</VersionId>").count(),
        1,
        "suspended PUT must not create a duplicate null version, got: {}",
        after
    );

    let resp = app
        .clone()
        .oneshot(signed_request(
            Method::GET,
            "/null-purge/k?versionId=null",
            Body::empty(),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(
        &bytes[..],
        b"suspended-null",
        "the surviving null version must be the most recent (suspended) write"
    );
}

#[tokio::test]
async fn test_cluster_overview_matches_peer_inbound_access_key_not_outbound_connection() {
    const ADMIN_AK: &str = "AKIAADMINADMINADMIN0";
    const ADMIN_SK: &str = "admin-secret-admin-secret-admin-secret00";
    const PEER_AK: &str = "AKIAPEERPEERPEERPEER";
    const PEER_SK: &str = "peer-secret-peer-secret-peer-secret-peer";
    const OUTBOUND_AK: &str = "AKIAOUTBOUNDOUTBOUND";
    const OUTBOUND_SK: &str = "outbound-secret-outbound-secret-outbound";

    let iam_json = serde_json::json!({
        "version": 2,
        "users": [
            {
                "user_id": "u-admin",
                "display_name": "admin",
                "enabled": true,
                "access_keys": [{
                    "access_key": ADMIN_AK,
                    "secret_key": ADMIN_SK,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "*",
                    "actions": ["*"],
                    "prefix": "*"
                }]
            },
            {
                "user_id": "u-peer",
                "display_name": "peer",
                "enabled": true,
                "access_keys": [{
                    "access_key": PEER_AK,
                    "secret_key": PEER_SK,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "peer-bucket",
                    "actions": ["read"],
                    "prefix": "*"
                }]
            },
            {
                "user_id": "u-outbound",
                "display_name": "outbound",
                "enabled": true,
                "access_keys": [{
                    "access_key": OUTBOUND_AK,
                    "secret_key": OUTBOUND_SK,
                    "status": "active"
                }],
                "policies": [{
                    "bucket": "outbound-bucket",
                    "actions": ["read"],
                    "prefix": "*"
                }]
            }
        ]
    });

    let tmp = tempfile::TempDir::new().unwrap();
    let iam_path = tmp.path().join(".myfsio.sys").join("config");
    std::fs::create_dir_all(&iam_path).unwrap();
    std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap();

    let config = myfsio_server::config::ServerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
        storage_root: tmp.path().to_path_buf(),
        region: "us-east-1".to_string(),
        iam_config_path: iam_path.join("iam.json"),
        sigv4_timestamp_tolerance_secs: 900,
        presigned_url_min_expiry: 1,
        presigned_url_max_expiry: 604800,
        secret_key: None,
        encryption_enabled: false,
        kms_enabled: false,
        gc_enabled: false,
        integrity_enabled: false,
        metrics_enabled: false,
        metrics_history_enabled: false,
        metrics_interval_minutes: 5,
        metrics_retention_hours: 24,
        metrics_history_interval_minutes: 5,
        metrics_history_retention_hours: 24,
        lifecycle_enabled: false,
        website_hosting_enabled: false,
        replication_connect_timeout_secs: 5,
        replication_read_timeout_secs: 30,
        replication_max_retries: 2,
        replication_streaming_threshold_bytes: 10_485_760,
        replication_max_failures_per_bucket: 50,
        site_sync_enabled: false,
        site_sync_interval_secs: 60,
        site_sync_batch_size: 100,
        site_sync_connect_timeout_secs: 10,
        site_sync_read_timeout_secs: 120,
        site_sync_max_retries: 2,
        site_sync_clock_skew_tolerance: 1.0,
        ui_enabled: false,
        templates_dir: std::path::PathBuf::from("templates"),
        static_dir: std::path::PathBuf::from("static"),
        multipart_min_part_size: 1,
        ..myfsio_server::config::ServerConfig::default()
    };
    let state = myfsio_server::state::AppState::new(config);

    state
        .connections
        .add(myfsio_server::stores::connections::RemoteConnection {
            id: "conn-to-peer".to_string(),
            name: "Peer".to_string(),
            endpoint_url: "http://127.0.0.1:1".to_string(),
            access_key: OUTBOUND_AK.to_string(),
            secret_key: OUTBOUND_SK.to_string(),
            region: "us-east-1".to_string(),
        })
        .unwrap();

    let app = myfsio_server::create_router(state.clone());

    let register_body = serde_json::json!({
        "site_id": "peer-site",
        "endpoint": "http://peer.example.com",
        "region": "us-east-1",
        "priority": 100,
        "display_name": "Peer Site",
        "connection_id": "conn-to-peer",
        "peer_inbound_access_key": PEER_AK,
    });
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri("/admin/sites")
                .header("x-access-key", ADMIN_AK)
                .header("x-secret-key", ADMIN_SK)
                .header("content-type", "application/json")
                .body(Body::from(register_body.to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::CREATED,
        "admin must be able to register peer with peer_inbound_access_key"
    );
    let resp_bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let registered: Value = serde_json::from_slice(&resp_bytes).unwrap();
    assert_eq!(registered["peer_inbound_access_key"], PEER_AK);
    assert_eq!(registered["connection_id"], "conn-to-peer");

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/admin/cluster/overview")
                .header("x-access-key", PEER_AK)
                .header("x-secret-key", PEER_SK)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "registered peer's inbound access key must be authorized for cluster overview"
    );

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/admin/cluster/overview")
                .header("x-access-key", OUTBOUND_AK)
                .header("x-secret-key", OUTBOUND_SK)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "outbound connection access key must NOT grant cluster overview when not configured as the peer's inbound key"
    );
}
