// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, local_http_client, signed_request};
use aws_sdk_s3::primitives::ByteStream;
use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_config::{ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, ENV_NOTIFY_ENABLE};
use rustfs_signer::pre_sign_v4;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use s3s::Body;
use std::collections::HashMap;
use std::error::Error;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Duration, timeout};

#[derive(Debug)]
struct CapturedWebhookRequest {
    headers: HashMap<String, String>,
    payload: serde_json::Value,
}

struct WebhookResponseSpec {
    status_line: String,
    body: Vec<u8>,
    headers: Vec<(String, String)>,
    include_auth_headers: bool,
    auth_route_override: Option<String>,
    auth_token_override: Option<String>,
}

fn find_header_terminator(buf: &[u8]) -> Option<usize> {
    buf.windows(4).position(|window| window == b"\r\n\r\n")
}

async fn read_http_request(
    stream: &mut tokio::net::TcpStream,
) -> Result<(String, HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
    let mut buffer = Vec::new();
    let mut chunk = [0_u8; 4096];

    let header_end = loop {
        let read = stream.read(&mut chunk).await?;
        if read == 0 {
            return Err("webhook request ended before headers were fully received".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
        if let Some(pos) = find_header_terminator(&buffer) {
            break pos;
        }
    };

    let header_bytes = &buffer[..header_end];
    let header_text = std::str::from_utf8(header_bytes)?;
    let mut lines = header_text.split("\r\n");
    let request_line = lines.next().ok_or("missing request line")?.to_string();
    let mut headers = HashMap::new();
    for line in lines {
        if line.is_empty() {
            continue;
        }
        let (name, value) = line.split_once(':').ok_or("invalid header line")?;
        headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
    }

    let content_length = headers
        .get("content-length")
        .map(|value| value.parse::<usize>())
        .transpose()?
        .unwrap_or_default();
    let body_offset = header_end + 4;
    while buffer.len().saturating_sub(body_offset) < content_length {
        let read = stream.read(&mut chunk).await?;
        if read == 0 {
            return Err("webhook request ended before body was fully received".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
    }

    Ok((request_line, headers, buffer[body_offset..body_offset + content_length].to_vec()))
}

async fn spawn_object_lambda_webhook_server() -> Result<
    (
        String,
        oneshot::Receiver<CapturedWebhookRequest>,
        tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
    ),
    Box<dyn Error + Send + Sync>,
> {
    spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
        status_line: "200 OK".to_string(),
        body: b"transformed through object lambda".to_vec(),
        headers: vec![("content-type".to_string(), "text/plain".to_string())],
        include_auth_headers: true,
        auth_route_override: None,
        auth_token_override: None,
    })
    .await
}

async fn spawn_object_lambda_webhook_server_with_response(
    response_spec: WebhookResponseSpec,
) -> Result<
    (
        String,
        oneshot::Receiver<CapturedWebhookRequest>,
        tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
    ),
    Box<dyn Error + Send + Sync>,
> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let address = listener.local_addr()?;
    let webhook_url = format!("http://{address}/transform");
    let (request_tx, request_rx) = oneshot::channel();

    let handle = tokio::spawn(async move {
        loop {
            let (mut stream, _) = listener.accept().await?;
            let Ok(Ok((request_line, headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await
            else {
                continue;
            };
            if request_line == "HEAD / HTTP/1.1" {
                stream
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
                    .await?;
                stream.shutdown().await?;
                continue;
            }
            let payload: serde_json::Value = serde_json::from_slice(&body)?;

            let output_route = payload["getObjectContext"]["outputRoute"]
                .as_str()
                .ok_or("missing outputRoute in webhook payload")?
                .to_string();
            let output_token = payload["getObjectContext"]["outputToken"]
                .as_str()
                .ok_or("missing outputToken in webhook payload")?
                .to_string();

            let _ = request_tx.send(CapturedWebhookRequest { headers, payload });

            let mut response_head = format!(
                "HTTP/1.1 {}\r\ncontent-length: {}\r\nconnection: close\r\n",
                response_spec.status_line,
                response_spec.body.len()
            );
            for (name, value) in &response_spec.headers {
                response_head.push_str(&format!("{name}: {value}\r\n"));
            }
            if response_spec.include_auth_headers {
                let auth_route = response_spec.auth_route_override.as_deref().unwrap_or(&output_route);
                let auth_token = response_spec.auth_token_override.as_deref().unwrap_or(&output_token);
                response_head.push_str(&format!("x-amz-request-route: {auth_route}\r\n"));
                response_head.push_str(&format!("x-amz-request-token: {auth_token}\r\n"));
            }
            response_head.push_str("\r\n");
            stream.write_all(response_head.as_bytes()).await?;
            stream.write_all(&response_spec.body).await?;
            stream.shutdown().await?;

            return Ok(());
        }
    });

    Ok((webhook_url, request_rx, handle))
}

async fn read_request_path(stream: &mut tokio::net::TcpStream) -> Result<String, Box<dyn Error + Send + Sync>> {
    let mut buffer = Vec::new();
    let mut chunk = [0_u8; 4096];

    let header_end = loop {
        let read = stream.read(&mut chunk).await?;
        if read == 0 {
            return Err("request ended before headers were fully received".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
        if let Some(pos) = find_header_terminator(&buffer) {
            break pos;
        }
    };

    let header_text = std::str::from_utf8(&buffer[..header_end])?;
    let request_line = header_text.lines().next().ok_or("missing request line")?;
    let path = request_line.split_whitespace().nth(1).ok_or("missing request path")?;

    Ok(path.to_string())
}

async fn presigned_get_request(
    url: &str,
    access_key: &str,
    secret_key: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
    let uri = url.parse::<http::Uri>()?;
    let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
    let signed = pre_sign_v4(
        http::Request::builder()
            .method(http::Method::GET)
            .uri(uri)
            .header(HOST, authority)
            .body(Body::empty())?,
        access_key,
        secret_key,
        "",
        "us-east-1",
        600,
        OffsetDateTime::now_utc(),
    );

    Ok(local_http_client().get(signed.uri().to_string()).send().await?)
}

async fn configure_webhook_target(
    env: &RustFSTestEnvironment,
    target_name: &str,
    endpoint: &str,
    auth_token: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
    configure_webhook_target_with_key_values(
        env,
        target_name,
        vec![
            ("endpoint", endpoint.to_string()),
            ("auth_token", auth_token.to_string()),
            ("queue_dir", format!("{}/notify-queue", env.temp_dir)),
        ],
    )
    .await
}

async fn configure_webhook_target_with_key_values(
    env: &RustFSTestEnvironment,
    target_name: &str,
    key_values: Vec<(&str, String)>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
    let queue_dir = format!("{}/notify-queue", env.temp_dir);
    tokio::fs::create_dir_all(&queue_dir).await?;
    let mut key_values = key_values
        .into_iter()
        .map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
        .collect::<Vec<_>>();
    if !key_values.iter().any(|entry| entry["key"].as_str() == Some("queue_dir")) {
        key_values.push(serde_json::json!({ "key": "queue_dir", "value": queue_dir }));
    }
    let response = send_configure_webhook_target_request(env, target_name, key_values).await?;
    if response.status() != StatusCode::OK {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        return Err(format!("failed to configure object lambda webhook target: {status} {body}").into());
    }

    Ok(())
}

async fn send_configure_webhook_target_request(
    env: &RustFSTestEnvironment,
    target_name: &str,
    key_values: Vec<serde_json::Value>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
    let payload = serde_json::json!({ "key_values": key_values });
    let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{}", env.url, target_name);
    signed_request(
        http::Method::PUT,
        &url,
        &env.access_key,
        &env.secret_key,
        Some(payload.to_string().into_bytes()),
        Some("application/json"),
    )
    .await
}

async fn list_notification_targets(env: &RustFSTestEnvironment) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
    let url = format!("{}/rustfs/admin/v3/target/list", env.url);
    let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.bytes().await?;
    if status != StatusCode::OK {
        return Err(format!("failed to list notification targets: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
    }

    Ok(serde_json::from_slice(&body)?)
}

async fn list_target_arns(env: &RustFSTestEnvironment) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
    let url = format!("{}/rustfs/admin/v3/target/arns", env.url);
    let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.bytes().await?;
    if status != StatusCode::OK {
        return Err(format!("failed to list target arns: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
    }

    Ok(serde_json::from_slice(&body)?)
}

async fn delete_webhook_target(env: &RustFSTestEnvironment, target_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
    let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}/reset", env.url);
    let response = signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.text().await.unwrap_or_default();
    if status != StatusCode::OK {
        return Err(format!("failed to delete webhook target {target_name}: {status} {body}").into());
    }

    Ok(())
}

fn notification_target_is_listed(targets: &serde_json::Value, target_name: &str) -> bool {
    notification_target_entry(targets, target_name).is_some()
}

fn notification_target_entry<'a>(targets: &'a serde_json::Value, target_name: &str) -> Option<&'a serde_json::Value> {
    targets["notification_endpoints"]
        .as_array()
        .into_iter()
        .flatten()
        .find(|entry| {
            entry["account_id"].as_str() == Some(target_name)
                && entry["service"]
                    .as_str()
                    .is_some_and(|service| service == "webhook" || service.starts_with("webhook-"))
        })
}

fn notification_target_status<'a>(targets: &'a serde_json::Value, target_name: &str) -> Option<&'a str> {
    notification_target_entry(targets, target_name).and_then(|entry| entry["status"].as_str())
}

async fn wait_for_target_visibility(
    env: &RustFSTestEnvironment,
    target_name: &str,
) -> Result<(serde_json::Value, Vec<String>), Box<dyn Error + Send + Sync>> {
    let mut last_targets = serde_json::Value::Null;
    let mut last_arns = Vec::new();

    for _ in 0..20 {
        last_targets = list_notification_targets(env).await?;
        last_arns = list_target_arns(env).await?;

        if notification_target_is_listed(&last_targets, target_name) {
            return Ok((last_targets, last_arns));
        }

        tokio::time::sleep(Duration::from_millis(250)).await;
    }

    Err(format!("target {target_name} did not become visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
}

async fn wait_for_target_absence(
    env: &RustFSTestEnvironment,
    target_name: &str,
) -> Result<(serde_json::Value, Vec<String>), Box<dyn Error + Send + Sync>> {
    let mut last_targets = serde_json::Value::Null;
    let mut last_arns = Vec::new();

    for _ in 0..20 {
        last_targets = list_notification_targets(env).await?;
        last_arns = list_target_arns(env).await?;

        let listed = notification_target_is_listed(&last_targets, target_name);
        let arn_listed = last_arns.iter().any(|arn| arn.ends_with(&format!(":{target_name}:webhook")));
        if !listed && !arn_listed {
            return Ok((last_targets, last_arns));
        }

        tokio::time::sleep(Duration::from_millis(250)).await;
    }

    Err(format!("target {target_name} remained visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
}

fn endpoint_origin(endpoint: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
    Ok(reqwest::Url::parse(endpoint)?.origin().ascii_serialization())
}

async fn start_rustfs_server_for_endpoint(
    env: &mut RustFSTestEnvironment,
    endpoint: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
    let origin = endpoint_origin(endpoint)?;
    env.start_rustfs_server_with_env(
        vec![],
        &[
            (ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
            ("RUSTFS_NOTIFY_ENABLE", "true"),
        ],
    )
    .await
}

async fn restart_rustfs_server(env: &mut RustFSTestEnvironment, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
    let origin = endpoint_origin(endpoint)?;
    env.stop_server();
    env.start_rustfs_server_without_cleanup_with_env(&[
        (ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
        ("RUSTFS_NOTIFY_ENABLE", "true"),
    ])
    .await
}

async fn spawn_http_origin_probe_server() -> Result<
    (
        String,
        mpsc::Receiver<String>,
        tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
    ),
    Box<dyn Error + Send + Sync>,
> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let address = listener.local_addr()?;
    let webhook_url = format!("http://{address}/hook");
    let (path_tx, path_rx) = mpsc::channel(1);

    let handle = tokio::spawn(async move {
        loop {
            let (mut stream, _) = listener.accept().await?;
            let path = timeout(Duration::from_secs(2), read_request_path(&mut stream)).await??;
            let _ = path_tx.try_send(path.clone());
            if path == "/" {
                let response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
                stream.write_all(response).await?;
            }
        }
    });

    Ok((webhook_url, path_rx, handle))
}

async fn read_persisted_server_config(env: &RustFSTestEnvironment) -> String {
    let path = format!("{}/.rustfs.sys/config/config.json", env.temp_dir);
    match tokio::fs::read_to_string(&path).await {
        Ok(content) => content,
        Err(err) if err.kind() == std::io::ErrorKind::IsADirectory => {
            let mut entries = Vec::new();
            match tokio::fs::read_dir(&path).await {
                Ok(mut dir) => {
                    while let Ok(Some(entry)) = dir.next_entry().await {
                        entries.push(entry.file_name().to_string_lossy().to_string());
                    }
                    entries.sort();
                    format!("persisted config stored as object directory at {path}; entries={entries:?}")
                }
                Err(dir_err) => format!("persisted config directory exists at {path} but could not be listed: {dir_err}"),
            }
        }
        Err(err) => format!("failed to read persisted config at {path}: {err}"),
    }
}

async fn read_listen_notification_event(
    response: reqwest::Response,
    expected_key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
    let mut response = response;
    let mut pending = String::new();
    loop {
        let chunk = timeout(Duration::from_secs(12), response.chunk()).await??;
        let Some(chunk) = chunk else {
            return Err("listen_notification stream ended before payload".into());
        };
        if chunk.is_empty() {
            continue;
        }
        pending.push_str(&String::from_utf8(chunk.to_vec())?);

        while let Some(newline) = pending.find('\n') {
            let line = pending.drain(..=newline).collect::<String>();
            let payload = line.trim();
            if payload.is_empty() {
                continue;
            }

            let json: serde_json::Value = serde_json::from_str(payload)?;
            let Some(records) = json["Records"].as_array() else {
                continue;
            };
            if records.is_empty() {
                continue;
            }

            let has_expected_key = records.iter().any(|record| {
                let Some(object_key) = record["s3"]["object"]["key"].as_str() else {
                    return false;
                };
                let decoded = urlencoding::decode(object_key)
                    .map(|decoded| decoded.into_owned())
                    .unwrap_or_else(|_| object_key.to_string());
                decoded == expected_key
            });
            if has_expected_key {
                return Ok(payload.to_string());
            }
        }
    }
}

#[tokio::test]
async fn test_notification_target_persists_across_restart_and_delete() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let target_name = "restart-target";
    configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;

    let (visible_targets, visible_arns) = wait_for_target_visibility(&env, target_name).await?;
    assert!(notification_target_is_listed(&visible_targets, target_name));
    assert!(
        visible_arns
            .iter()
            .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))),
        "target ARN missing after initial configure: {visible_arns:?}"
    );

    restart_rustfs_server(&mut env, &webhook_url).await?;

    let (targets_after_restart, arns_after_restart) = wait_for_target_visibility(&env, target_name).await?;
    assert!(notification_target_is_listed(&targets_after_restart, target_name));
    assert!(
        arns_after_restart
            .iter()
            .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))),
        "target ARN missing after restart: {arns_after_restart:?}"
    );

    delete_webhook_target(&env, target_name).await?;
    let (targets_after_delete, arns_after_delete) = wait_for_target_absence(&env, target_name).await?;
    assert!(!notification_target_is_listed(&targets_after_delete, target_name));
    assert!(
        !arns_after_delete
            .iter()
            .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))),
        "target ARN still visible after delete: {arns_after_delete:?}"
    );

    restart_rustfs_server(&mut env, &webhook_url).await?;

    let (targets_after_delete_restart, arns_after_delete_restart) = wait_for_target_absence(&env, target_name).await?;
    assert!(!notification_target_is_listed(&targets_after_delete_restart, target_name));
    assert!(
        !arns_after_delete_restart
            .iter()
            .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))),
        "target ARN still visible after delete + restart: {arns_after_delete_restart:?}"
    );

    webhook_handle.abort();
    let _ = webhook_handle.await;

    Ok(())
}

#[tokio::test]
async fn test_notification_target_with_path_is_online_via_transport_probe() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, mut probe_rx, probe_handle) = spawn_http_origin_probe_server().await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let target_name = "path-probe";
    configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;

    let (visible_targets, visible_arns) = wait_for_target_visibility(&env, target_name).await?;
    assert_eq!(notification_target_status(&visible_targets, target_name), Some("online"));
    let observed_path = timeout(Duration::from_secs(10), probe_rx.recv())
        .await
        .map_err(|_| "probe server timed out waiting for a request")?
        .ok_or("probe server did not observe a request")?;
    assert_eq!(observed_path, "/");
    assert!(
        visible_arns
            .iter()
            .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))),
        "target ARN missing for reachable path endpoint: {visible_arns:?}"
    );

    probe_handle.abort();
    let _ = probe_handle.await;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e-presigned";
    let key = "input.txt";
    let object_body = b"hello presigned object lambda";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(object_body))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = presigned_get_request(&lambda_url, &env.access_key, &env.secret_key).await?;
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.text().await?, "transformed through object lambda");

    let captured = timeout(Duration::from_secs(10), request_rx).await??;
    assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e-named-target";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook-preview";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.text().await?, "transformed through object lambda");

    let captured = timeout(Duration::from_secs(10), request_rx).await??;
    assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e";
    let key = "input.txt";
    let object_body = b"hello object lambda";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(object_body))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    let (visible_targets, visible_arns) = wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    if response.status() != StatusCode::OK {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        let persisted_config = read_persisted_server_config(&env).await;
        return Err(format!(
            "object lambda request failed: {status} {body}; visible_targets={visible_targets}; visible_arns={visible_arns:?}; persisted_config={persisted_config}"
        )
        .into());
    }
    assert_eq!(
        response.headers().get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
        Some("text/plain")
    );
    assert_eq!(response.text().await?, "transformed through object lambda");

    let captured = timeout(Duration::from_secs(10), request_rx).await??;
    assert_eq!(captured.headers.get("authorization").map(String::as_str), Some("Bearer secret-token"));
    assert_eq!(captured.headers.get("x-rustfs-object-lambda-bucket").map(String::as_str), Some(bucket));
    assert_eq!(captured.headers.get("x-rustfs-object-lambda-key").map(String::as_str), Some(key));

    assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
    let expected_request_url = format!("/{bucket}/{key}?lambdaArn={}", urlencoding::encode(lambda_arn));
    assert_eq!(captured.payload["userRequest"]["url"].as_str(), Some(expected_request_url.as_str()));

    let input_s3_url = captured.payload["getObjectContext"]["inputS3Url"]
        .as_str()
        .ok_or("missing inputS3Url in object lambda payload")?;
    assert!(!input_s3_url.contains("lambdaArn="));

    let source_response = local_http_client().get(input_s3_url).send().await?;
    assert_eq!(source_response.status(), StatusCode::OK);
    assert_eq!(source_response.bytes().await?.as_ref(), object_body);

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
        status_line: "418 I'm a teapot".to_string(),
        body: b"lambda upstream rejected".to_vec(),
        headers: vec![
            ("content-type".to_string(), "text/plain".to_string()),
            ("x-rustfs-debug".to_string(), "passthrough".to_string()),
            ("x-amz-request-route".to_string(), "should-not-leak".to_string()),
            ("x-amz-request-token".to_string(), "should-not-leak".to_string()),
        ],
        include_auth_headers: false,
        auth_route_override: None,
        auth_token_override: None,
    })
    .await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e-failure";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
    assert_eq!(
        response.headers().get("content-type").and_then(|value| value.to_str().ok()),
        Some("text/plain")
    );
    assert_eq!(
        response.headers().get("x-rustfs-debug").and_then(|value| value.to_str().ok()),
        Some("passthrough")
    );
    assert!(response.headers().get("x-amz-request-route").is_none());
    assert!(response.headers().get("x-amz-request-token").is_none());
    assert_eq!(response.text().await?, "lambda upstream rejected");

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_rejects_success_response_without_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
        status_line: "200 OK".to_string(),
        body: b"missing auth headers".to_vec(),
        headers: vec![("content-type".to_string(), "text/plain".to_string())],
        include_auth_headers: false,
        auth_route_override: None,
        auth_token_override: None,
    })
    .await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e-missing-auth";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body = response.text().await?;
    assert!(body.contains("authorization headers"), "unexpected error body: {body}");

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>>
{
    init_logging();

    let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
        status_line: "200 OK".to_string(),
        body: b"mismatched auth headers".to_vec(),
        headers: vec![("content-type".to_string(), "text/plain".to_string())],
        include_auth_headers: true,
        auth_route_override: Some("wrong-route".to_string()),
        auth_token_override: Some("wrong-token".to_string()),
    })
    .await?;

    let mut env = RustFSTestEnvironment::new().await?;
    start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;

    let bucket = "object-lambda-e2e-mismatched-auth";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
    wait_for_target_visibility(&env, "transformer").await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body = response.text().await?;
    assert!(body.contains("authorization headers"), "unexpected error body: {body}");

    webhook_handle.await??;

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server(vec![]).await?;

    let bucket = "object-lambda-e2e-unsupported-target";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:mqtt";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.text().await?;

    assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
    assert!(body.contains("NotImplemented"), "unexpected error body: {body}");
    assert!(
        body.to_ascii_lowercase().contains("target type is not supported"),
        "unexpected error body: {body}"
    );

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server(vec![]).await?;

    let bucket = "object-lambda-e2e-missing-target";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.text().await?;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
    assert!(
        body.to_ascii_lowercase().contains("target is not configured"),
        "unexpected error body: {body}"
    );

    Ok(())
}

#[tokio::test]
async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
        .await?;

    let bucket = "object-lambda-e2e-disabled-target";
    let key = "input.txt";
    let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    let queue_dir = format!("{}/disabled-target-queue", env.temp_dir);
    tokio::fs::create_dir_all(&queue_dir).await?;
    let config_url = format!("{}/rustfs/admin/v3/set-config-kv", env.url);
    let directive = format!(
        "notify_webhook:transformer enable=off endpoint=\"http://127.0.0.1:9/transform\" auth_token=\"secret-token\" queue_dir=\"{queue_dir}\""
    );
    let disable_response = signed_request(
        http::Method::PUT,
        &config_url,
        &env.access_key,
        &env.secret_key,
        Some(directive.into_bytes()),
        Some("text/plain"),
    )
    .await?;
    let disable_status = disable_response.status();
    let disable_body = disable_response.text().await?;
    assert_eq!(disable_status, StatusCode::OK, "failed to disable target: {disable_body}");

    let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
    let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
    let status = response.status();
    let body = response.text().await?;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
    assert!(body.to_ascii_lowercase().contains("target is disabled"), "unexpected error body: {body}");

    Ok(())
}

#[tokio::test]
async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
        .await?;

    let bucket = "object-lambda-e2e-invalid-endpoint";

    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    client
        .put_object()
        .bucket(bucket)
        .key("input.txt")
        .body(ByteStream::from_static(b"hello object lambda"))
        .send()
        .await?;

    let response = send_configure_webhook_target_request(
        &env,
        "transformer",
        vec![
            serde_json::json!({ "key": "endpoint", "value": "://invalid-endpoint" }),
            serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
            serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
        ],
    )
    .await?;
    let status = response.status();
    let body = response.text().await?;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
    assert!(
        body.to_ascii_lowercase().contains("invalid endpoint url"),
        "unexpected error body: {body}"
    );

    Ok(())
}

#[tokio::test]
async fn test_configure_object_lambda_notify_webhook_rejects_response_header_timeout_key()
-> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
        .await?;

    let response = send_configure_webhook_target_request(
        &env,
        "transformer",
        vec![
            serde_json::json!({ "key": "endpoint", "value": "http://127.0.0.1:9/transform" }),
            serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
            serde_json::json!({ "key": "response_header_timeout", "value": "not-a-duration" }),
            serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
        ],
    )
    .await?;
    let status = response.status();
    let body = response.text().await?;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
    assert!(
        body.to_ascii_lowercase().contains("response_header_timeout"),
        "unexpected error body: {body}"
    );
    assert!(body.to_ascii_lowercase().contains("not allowed"), "unexpected error body: {body}");

    Ok(())
}

#[tokio::test]
async fn test_listen_notification_emits_after_put_object() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server(vec![]).await?;

    let bucket = "listen-notification-e2e";
    let key = "logs/app.json";
    let client = env.create_s3_client();

    client.create_bucket().bucket(bucket).send().await?;

    let listen_url = format!(
        "{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
        env.url,
        urlencoding::encode("s3:ObjectCreated:Put"),
        urlencoding::encode("logs/"),
        urlencoding::encode(".json"),
    );
    let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(
        response.headers().get("content-type").and_then(|value| value.to_str().ok()),
        Some("text/event-stream")
    );

    let read_task = tokio::spawn(read_listen_notification_event(response, key));

    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"listen notification body"))
        .send()
        .await?;

    let payload = timeout(Duration::from_secs(12), read_task).await???;
    assert!(!payload.is_empty(), "listen_notification payload should not be empty");

    Ok(())
}

#[tokio::test]
async fn test_listen_notification_emits_on_empty_bucket_when_notify_disabled() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "false")])
        .await?;

    let bucket = "listen-empty-bucket-e2e";
    let key = "seed/object.txt";
    let client = env.create_s3_client();

    client.create_bucket().bucket(bucket).send().await?;

    let listen_url = format!("{}/{bucket}?events={}&ping=1", env.url, urlencoding::encode("s3:ObjectCreated:*"),);
    let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::OK);

    let read_task = tokio::spawn(read_listen_notification_event(response, key));

    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"empty bucket watch body"))
        .send()
        .await?;

    let payload = timeout(Duration::from_secs(12), read_task).await???;
    assert!(!payload.is_empty(), "listen_notification payload should not be empty");

    Ok(())
}

#[tokio::test]
async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box<dyn Error + Send + Sync>> {
    init_logging();

    let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
    cluster.set_env(ENV_NOTIFY_ENABLE, "true");
    cluster.set_env(ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, "1");
    cluster.start().await?;

    let bucket = "listen-notification-cluster";
    let key = "logs/cluster.json";
    let node0_client = cluster.create_s3_client(0)?;
    let node1_client = cluster.create_s3_client(1)?;

    node0_client.create_bucket().bucket(bucket).send().await?;

    let listen_url = format!(
        "{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
        cluster.nodes[0].url,
        urlencoding::encode("s3:ObjectCreated:Put"),
        urlencoding::encode("logs/"),
        urlencoding::encode(".json"),
    );
    let response = signed_request(http::Method::GET, &listen_url, &cluster.access_key, &cluster.secret_key, None, None).await?;
    assert_eq!(response.status(), StatusCode::OK);

    let read_task = tokio::spawn(read_listen_notification_event(response, key));

    node1_client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"cluster listen notification body"))
        .send()
        .await?;

    let payload = timeout(Duration::from_secs(12), read_task).await???;
    assert!(!payload.is_empty(), "listen_notification cluster payload should not be empty");

    Ok(())
}
