// 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.

//! End-to-end regression net for the S3 event-notification pipeline
//! (backlog#1154 peri-1). Proves the full "configure webhook target ->
//! PutBucketNotificationConfiguration -> object operation -> event delivered"
//! chain against a real rustfs binary and a real HTTP receiver, which no other
//! test covers: the target-plugin suites stop at broker integration and the
//! object-lambda suite only exercises the webhook target as a transform
//! function, never as an S3 event sink.
//!
//! Coverage:
//!   * PUT / multipart-complete / DeleteObject / DeleteObjects each deliver one event with the correct
//!     eventName, bucket, key, versionId and eTag.
//!   * prefix/suffix filters drop non-matching keys (rule-engine gate).
//!   * an event queued while the target endpoint rejects delivery is redelivered
//!     from the on-disk store once the endpoint recovers (store-and-forward).
//!   * responseElements and the S3 response use the canonical request ID while
//!     requestParameters preserve a conflicting client-supplied value.

use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::operation::RequestId;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
    BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Delete, Event, FilterRule, FilterRuleName,
    NotificationConfiguration, NotificationConfigurationFilter, ObjectIdentifier, QueueConfiguration, S3KeyFilter,
    VersioningConfiguration,
};
use local_ip_address::local_ip;
use reqwest::StatusCode;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use rustfs_utils::http::headers::{AMZ_REQUEST_ID, REQUEST_ID_HEADER};
use serde_json::Value;
use std::error::Error;
use std::io::Cursor;
use std::path::Path;
use std::sync::{
    Arc, Once,
    atomic::{AtomicBool, Ordering},
};
use std::thread;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::{Duration, Instant, timeout};

type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
type BoxError = Box<dyn Error + Send + Sync>;

/// Region embedded in the target ARN. Only the `id:name` tail of the ARN is used
/// for target lookup (see `process_queue_configurations`), so the region is
/// nominal, but it must be present for `ARN::parse` to succeed.
const NOTIFY_REGION: &str = "us-east-1";
const CLIENT_REQUEST_ID: &str = "client-supplied-request-id";
const CLIENT_AMZ_REQUEST_ID: &str = "client-supplied-amz-request-id";

/// Webhook targets are registered as `TargetID { id: <name>, name: "webhook" }`,
/// so the ARN a notification rule references is
/// `arn:rustfs:sqs:<region>:<name>:webhook`.
fn target_arn(target_name: &str) -> String {
    format!("arn:rustfs:sqs:{NOTIFY_REGION}:{target_name}:webhook")
}

fn endpoint_origin(endpoint: &str) -> Result<String, BoxError> {
    let parsed = reqwest::Url::parse(endpoint)?;
    Ok(parsed.origin().ascii_serialization())
}

// ---------------------------------------------------------------------------
// In-test HTTP event receiver
// ---------------------------------------------------------------------------

/// Reads one HTTP request off the stream, returning its method and body. Handles
/// the target's HEAD reachability probe (no body) and POST event deliveries.
async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Result<(String, Vec<u8>), BoxError> {
    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("connection closed before request headers were complete".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
        if let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
            break pos;
        }
    };

    let header_text = std::str::from_utf8(&buffer[..header_end])?;
    let mut lines = header_text.split("\r\n");
    let request_line = lines.next().ok_or("missing request line")?;
    let method = request_line.split_whitespace().next().ok_or("missing method")?.to_string();

    let mut content_length = 0usize;
    for line in lines {
        if let Some((name, value)) = line.split_once(':')
            && name.trim().eq_ignore_ascii_case("content-length")
        {
            content_length = value.trim().parse::<usize>().unwrap_or(0);
        }
    }

    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("connection closed before request body was complete".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
    }

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

/// Drives an accepted listener: answers every request 200 OK (so the target's
/// HEAD reachability probe reports it online) and forwards each non-empty POST
/// body, parsed as the S3 event envelope JSON, to `tx`.
fn serve_event_collector(listener: TcpListener, tx: mpsc::UnboundedSender<Value>) -> JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            let Ok((mut stream, _)) = listener.accept().await else {
                return;
            };
            let tx = tx.clone();
            tokio::spawn(async move {
                if let Ok(Ok((method, body))) = timeout(Duration::from_secs(5), read_http_message(&mut stream)).await {
                    let _ = stream
                        .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
                        .await;
                    let _ = stream.shutdown().await;
                    if method == "POST"
                        && !body.is_empty()
                        && let Ok(value) = serde_json::from_slice::<Value>(&body)
                    {
                        let _ = tx.send(value);
                    }
                }
            });
        }
    })
}

/// Binds a fresh collector on a random port. Returns its `/events`
/// endpoint URL, a receiver of parsed event envelopes, and the serving task.
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>), BoxError> {
    let listener = TcpListener::bind("0.0.0.0:0").await?;
    let port = listener.local_addr()?.port();
    let endpoint_ip = local_ip()?;
    let (tx, rx) = mpsc::unbounded_channel();
    let handle = serve_event_collector(listener, tx);
    Ok((format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port)), rx, handle))
}

struct HttpsEventCollector {
    endpoint: String,
    running: Arc<AtomicBool>,
    handle: Option<thread::JoinHandle<()>>,
    events: mpsc::UnboundedReceiver<Value>,
}

impl HttpsEventCollector {
    fn endpoint(&self) -> &str {
        &self.endpoint
    }

    fn events_mut(&mut self) -> &mut mpsc::UnboundedReceiver<Value> {
        &mut self.events
    }

    fn shutdown(&mut self) -> TestResult {
        self.running.store(false, Ordering::Relaxed);
        if let Ok(parsed) = self.endpoint.parse::<reqwest::Url>()
            && let Some(port) = parsed.port()
        {
            let _ = std::net::TcpStream::connect(("127.0.0.1", port));
        }
        if let Some(handle) = self.handle.take() {
            handle.join().map_err(|_| "https event collector thread panicked")?;
        }
        Ok(())
    }
}

impl Drop for HttpsEventCollector {
    fn drop(&mut self) {
        let _ = self.shutdown();
    }
}

fn spawn_https_event_collector(ca_path: &Path) -> Result<HttpsEventCollector, BoxError> {
    use rustls::{
        ServerConfig,
        pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
    };
    use std::io::ErrorKind;
    use std::net::TcpListener as StdTcpListener;

    static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
    INSTALL_CRYPTO_PROVIDER.call_once(|| {
        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
    });

    let listener = StdTcpListener::bind("0.0.0.0:0")?;
    listener.set_nonblocking(true)?;
    let addr = listener.local_addr()?;
    let endpoint_ip = local_ip()?;
    let endpoint_host = endpoint_ip.to_string();

    let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host])?;
    std::fs::write(ca_path, cert.pem())?;

    let cert_chain = vec![cert.der().clone()];
    let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
    let server_config = Arc::new(
        ServerConfig::builder()
            .with_no_client_auth()
            .with_single_cert(cert_chain, key_der)?,
    );

    let running = Arc::new(AtomicBool::new(true));
    let server_running = Arc::clone(&running);
    let (tx, events) = mpsc::unbounded_channel();
    let handle = thread::spawn(move || {
        let mut connections = Vec::new();
        while server_running.load(Ordering::Relaxed) {
            match listener.accept() {
                Ok((stream, _)) => {
                    let config = Arc::clone(&server_config);
                    let tx = tx.clone();
                    connections.push(thread::spawn(move || {
                        let _ = handle_https_request(stream, config, tx);
                    }));
                }
                Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(20)),
                Err(_) => break,
            }
        }
        for connection in connections {
            let _ = connection.join();
        }
    });

    Ok(HttpsEventCollector {
        endpoint: format!("https://{}/events", std::net::SocketAddr::new(endpoint_ip, addr.port())),
        running,
        handle: Some(handle),
        events,
    })
}

fn read_sync_http_message<R: std::io::Read>(stream: &mut R) -> Result<(String, Vec<u8>), BoxError> {
    let mut buffer = Vec::new();
    let mut chunk = [0_u8; 4096];
    let header_end = loop {
        let read = stream.read(&mut chunk)?;
        if read == 0 {
            return Err("connection closed before request headers were complete".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
        if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
            break pos;
        }
    };

    let header_text = std::str::from_utf8(&buffer[..header_end])?;
    let mut lines = header_text.split("\r\n");
    let method = lines
        .next()
        .and_then(|line| line.split_whitespace().next())
        .ok_or("missing request method")?
        .to_string();
    let mut content_length = 0usize;
    for line in lines {
        if let Some((name, value)) = line.split_once(':')
            && name.trim().eq_ignore_ascii_case("content-length")
        {
            content_length = value.trim().parse()?;
        }
    }

    let body_offset = header_end + 4;
    while buffer.len().saturating_sub(body_offset) < content_length {
        let read = stream.read(&mut chunk)?;
        if read == 0 {
            return Err("connection closed before request body was complete".into());
        }
        buffer.extend_from_slice(&chunk[..read]);
    }
    Ok((method, buffer[body_offset..body_offset + content_length].to_vec()))
}

fn handle_https_request(
    stream: std::net::TcpStream,
    server_config: Arc<rustls::ServerConfig>,
    tx: mpsc::UnboundedSender<Value>,
) -> Result<(), BoxError> {
    use std::io::Write;

    stream.set_nonblocking(false)?;
    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
    let connection = rustls::ServerConnection::new(server_config)?;
    let mut tls_stream = rustls::StreamOwned::new(connection, stream);
    let (method, body) = read_sync_http_message(&mut tls_stream)?;
    let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
    tls_stream.write_all(response.as_bytes())?;
    tls_stream.flush()?;
    tls_stream.conn.send_close_notify();
    while tls_stream.conn.wants_write() {
        tls_stream.conn.write_tls(&mut tls_stream.sock)?;
    }
    let _ = tls_stream.sock.shutdown(std::net::Shutdown::Write);
    if method == "POST"
        && !body.is_empty()
        && let Ok(event) = serde_json::from_slice(&body)
    {
        let _ = tx.send(event);
    }
    Ok(())
}

/// Decoded object key of the first record in an event envelope.
fn event_key(envelope: &Value) -> Option<String> {
    let raw = envelope["Records"][0]["s3"]["object"]["key"].as_str()?;
    Some(
        urlencoding::decode(raw)
            .map(|d| d.into_owned())
            .unwrap_or_else(|_| raw.to_string()),
    )
}

/// Waits until an event targeting `key` whose `EventName` starts with
/// `event_prefix` arrives, returning the full envelope. Other envelopes are
/// discarded, so a lingering duplicate of an earlier event (delivery is
/// at-least-once) or the create event of a key that is later deleted never
/// satisfies a wait for the opposite event type.
async fn wait_for_event(
    rx: &mut mpsc::UnboundedReceiver<Value>,
    key: &str,
    event_prefix: &str,
    within: Duration,
) -> Result<Value, BoxError> {
    let deadline = Instant::now() + within;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Err(format!("no {event_prefix}* event for key {key} within {within:?}").into());
        }
        match timeout(remaining, rx.recv()).await {
            Ok(Some(envelope)) => {
                let key_matches = event_key(&envelope).as_deref() == Some(key);
                let name_matches = envelope["EventName"].as_str().is_some_and(|n| n.starts_with(event_prefix));
                if key_matches && name_matches {
                    return Ok(envelope);
                }
            }
            Ok(None) => return Err("event collector channel closed".into()),
            Err(_) => return Err(format!("no {event_prefix}* event for key {key} within {within:?}").into()),
        }
    }
}

/// Collects every event until `stop_key` is seen (plus a short grace window to
/// catch stragglers), or until `max_wait` elapses. Used to prove a negative:
/// non-matching keys must never appear even though a matching sentinel does.
async fn collect_until(
    rx: &mut mpsc::UnboundedReceiver<Value>,
    stop_key: &str,
    max_wait: Duration,
    grace: Duration,
) -> Vec<Value> {
    let hard_deadline = Instant::now() + max_wait;
    let mut soft_deadline: Option<Instant> = None;
    let mut collected = Vec::new();
    loop {
        let deadline = soft_deadline.unwrap_or(hard_deadline).min(hard_deadline);
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            break;
        }
        match timeout(remaining, rx.recv()).await {
            Ok(Some(envelope)) => {
                let matched = event_key(&envelope).as_deref() == Some(stop_key);
                collected.push(envelope);
                if matched && soft_deadline.is_none() {
                    soft_deadline = Some(Instant::now() + grace);
                }
            }
            Ok(None) => break,
            Err(_) => break,
        }
    }
    collected
}

// ---------------------------------------------------------------------------
// Admin target configuration (signed admin HTTP)
// ---------------------------------------------------------------------------

/// Thin wrapper over [`crate::common::signed_request`] with this suite's
/// root credentials; a `Some` body is always JSON here.
async fn signed_admin_request(
    env: &RustFSTestEnvironment,
    method: http::Method,
    url: &str,
    body: Option<Vec<u8>>,
) -> Result<reqwest::Response, BoxError> {
    let content_type = body.is_some().then_some("application/json");
    crate::common::signed_request(method, url, &env.access_key, &env.secret_key, body, content_type).await
}

async fn enable_notify_module(env: &RustFSTestEnvironment) -> TestResult {
    let payload = serde_json::json!({
        "notify_enabled": true,
        "audit_enabled": false,
    });
    let url = format!("{}/rustfs/admin/v3/module-switches", env.url);
    let response = signed_admin_request(env, http::Method::PUT, &url, Some(payload.to_string().into_bytes())).await?;
    if response.status() != StatusCode::OK {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        return Err(format!("failed to enable notify module: {status} {text}").into());
    }
    Ok(())
}

/// Registers a webhook notification target with a persistent queue directory, so
/// delivery goes through the durable store-and-forward path.
async fn configure_webhook_target(env: &RustFSTestEnvironment, target_name: &str, endpoint: &str) -> TestResult {
    configure_webhook_target_with_key_values(env, target_name, vec![("endpoint", endpoint.to_string())]).await
}

async fn configure_webhook_target_with_key_values(
    env: &RustFSTestEnvironment,
    target_name: &str,
    mut key_values: Vec<(&str, String)>,
) -> TestResult {
    let queue_dir = format!("{}/notify-queue-{target_name}", env.temp_dir);
    tokio::fs::create_dir_all(&queue_dir).await?;
    if !key_values.iter().any(|(key, _)| *key == "queue_dir") {
        key_values.push(("queue_dir", queue_dir));
    }
    let payload = serde_json::json!({
        "key_values": key_values
            .into_iter()
            .map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
            .collect::<Vec<_>>(),
    });
    let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}", env.url);
    let response = signed_admin_request(env, http::Method::PUT, &url, Some(payload.to_string().into_bytes())).await?;
    let status = response.status();
    if status != StatusCode::OK {
        let text = response.text().await.unwrap_or_default();
        return Err(format!("failed to configure webhook target {target_name}: {status} {text}").into());
    }
    Ok(())
}

/// Polls the admin target ARN list until the freshly configured target is
/// registered in the runtime, so notification rules and object events do not
/// race target activation.
async fn wait_for_target_registered(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
    let suffix = format!(":{target_name}:webhook");
    let url = format!("{}/rustfs/admin/v3/target/arns", env.url);
    for _ in 0..80 {
        let response = signed_admin_request(env, http::Method::GET, &url, None).await?;
        if response.status() == StatusCode::OK {
            let arns: Vec<String> = serde_json::from_slice(&response.bytes().await?)?;
            if arns.iter().any(|arn| arn.ends_with(&suffix)) {
                return Ok(());
            }
        }
        tokio::time::sleep(Duration::from_millis(250)).await;
    }
    Err(format!("target {target_name} was not registered in admin ARNs").into())
}

async fn wait_for_target_online(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
    let url = format!("{}/rustfs/admin/v3/target/list", env.url);
    for _ in 0..40 {
        let response = signed_admin_request(env, http::Method::GET, &url, None).await?;
        if response.status() == StatusCode::OK {
            let body: Value = serde_json::from_slice(&response.bytes().await?)?;
            if body["notify_enabled"].as_bool() != Some(true) {
                return Err(format!("admin target list did not report notify_enabled=true: {body}").into());
            }
            let listed = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
                endpoints.iter().any(|endpoint| {
                    endpoint["account_id"].as_str() == Some(target_name)
                        && endpoint["service"].as_str() == Some("webhook")
                        && endpoint["status"].as_str() == Some("online")
                })
            });
            if listed {
                return Ok(());
            }
        }
        tokio::time::sleep(Duration::from_millis(250)).await;
    }
    Err(format!("target {target_name} did not become online in admin targets").into())
}

/// Binds a bucket to a webhook target for ObjectCreated:*/ObjectRemoved:* events,
/// filtered to `prefix` + `suffix`.
async fn put_notification_config(client: &Client, bucket: &str, target_name: &str, prefix: &str, suffix: &str) -> TestResult {
    let key_filter = S3KeyFilter::builder()
        .filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value(prefix).build())
        .filter_rules(FilterRule::builder().name(FilterRuleName::Suffix).value(suffix).build())
        .build();
    let queue = QueueConfiguration::builder()
        .id(format!("{target_name}-rule"))
        .queue_arn(target_arn(target_name))
        .events(Event::from("s3:ObjectCreated:*"))
        .events(Event::from("s3:ObjectRemoved:*"))
        .filter(NotificationConfigurationFilter::builder().key(key_filter).build())
        .build()?;
    let config = NotificationConfiguration::builder().queue_configurations(queue).build();
    client
        .put_bucket_notification_configuration()
        .bucket(bucket)
        .notification_configuration(config)
        .send()
        .await?;
    Ok(())
}

async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
    client
        .put_bucket_versioning()
        .bucket(bucket)
        .versioning_configuration(
            VersioningConfiguration::builder()
                .status(BucketVersioningStatus::Enabled)
                .build(),
        )
        .send()
        .await?;
    Ok(())
}

fn trimmed_etag(value: Option<&str>) -> Option<String> {
    value.map(|e| e.trim_matches('"').to_string())
}

fn assert_conflicting_request_id_correlation(record: &Value, server_request_id: &str) {
    assert_eq!(
        record["requestParameters"][REQUEST_ID_HEADER].as_str(),
        Some(CLIENT_REQUEST_ID),
        "notification request parameters should retain the actual client header: {record}"
    );
    assert_eq!(
        record["requestParameters"][AMZ_REQUEST_ID].as_str(),
        Some(CLIENT_AMZ_REQUEST_ID),
        "notification request parameters should retain the actual client header: {record}"
    );
    assert_eq!(
        record["responseElements"][AMZ_REQUEST_ID].as_str(),
        Some(server_request_id),
        "notification response elements should use the canonical request ID: {record}"
    );
}

fn assert_generated_request_id_correlation(record: &Value, request_id: &str) {
    assert!(
        record["requestParameters"][AMZ_REQUEST_ID].is_null(),
        "notification request parameters must not invent a client request header: {record}"
    );
    assert_eq!(
        record["responseElements"][AMZ_REQUEST_ID].as_str(),
        Some(request_id),
        "notification response elements should match the S3 response request ID: {record}"
    );
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// Regression for rustfs#5052: with the notify module enabled through
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must become
/// online and receive a real S3 event POST.
#[tokio::test]
async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> TestResult {
    init_logging();

    let mut env = RustFSTestEnvironment::new().await?;
    let ca_path = Path::new(&env.temp_dir).join("https-webhook-ca.pem");
    let mut collector = spawn_https_event_collector(&ca_path)?;
    let allowed_origin = endpoint_origin(collector.endpoint())?;
    env.start_rustfs_server_with_env(
        vec![],
        &[
            ("RUSTFS_NOTIFY_ENABLE", "true"),
            (ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str()),
        ],
    )
    .await?;
    let target = "peri1https";
    let bucket = "peri1-https-events";
    let key = "uploads/https.dat";
    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;

    configure_webhook_target_with_key_values(
        &env,
        target,
        vec![
            ("endpoint", collector.endpoint().to_string()),
            ("client_ca", ca_path.to_string_lossy().into_owned()),
        ],
    )
    .await?;
    wait_for_target_online(&env, target).await?;
    wait_for_target_registered(&env, target).await?;
    put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;

    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"https webhook event body"))
        .send()
        .await?;
    let event = wait_for_event(collector.events_mut(), key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
    assert_eq!(event["EventName"].as_str(), Some("s3:ObjectCreated:Put"));
    assert_eq!(event["Records"][0]["s3"]["bucket"]["name"].as_str(), Some(bucket));
    assert_eq!(event_key(&event).as_deref(), Some(key));

    env.stop_server();
    collector.shutdown()?;
    Ok(())
}

/// PUT / multipart-complete / DELETE each deliver one event with correct fields,
/// and the prefix/suffix filter drops non-matching keys.
#[tokio::test]
async fn test_webhook_event_delivery_and_filtering() -> TestResult {
    init_logging();

    let (endpoint, mut rx, handle) = spawn_event_collector().await?;
    let allowed_origin = endpoint_origin(&endpoint)?;

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
        .await?;
    enable_notify_module(&env).await?;

    let bucket = "peri1-events";
    let target = "peri1events";
    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;
    enable_versioning(&client, bucket).await?;

    configure_webhook_target(&env, target, &endpoint).await?;
    wait_for_target_registered(&env, target).await?;
    put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;

    // --- PUT: ObjectCreated:Put with exact eTag + versionId ------------------
    let put_key = "uploads/report.dat";
    let put = client
        .put_object()
        .bucket(bucket)
        .key(put_key)
        .body(ByteStream::from_static(b"peri-1 put body"))
        .customize()
        .mutate_request(|request| {
            request.headers_mut().insert(REQUEST_ID_HEADER, CLIENT_REQUEST_ID);
            request.headers_mut().insert(AMZ_REQUEST_ID, CLIENT_AMZ_REQUEST_ID);
        })
        .send()
        .await?;
    let put_request_id = put.request_id().ok_or("PUT response missing request ID")?.to_owned();
    assert!(uuid::Uuid::parse_str(&put_request_id).is_ok());
    assert_ne!(put_request_id, CLIENT_REQUEST_ID);
    assert_ne!(put_request_id, CLIENT_AMZ_REQUEST_ID);
    let put_version = put
        .version_id()
        .ok_or("PUT response missing versionId (versioning not enabled?)")?;

    let created = wait_for_event(&mut rx, put_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
    let record = &created["Records"][0];
    let object = &record["s3"]["object"];
    assert_eq!(
        created["EventName"].as_str(),
        Some("s3:ObjectCreated:Put"),
        "envelope EventName: {created}"
    );
    assert!(
        record["eventName"]
            .as_str()
            .is_some_and(|n| n.starts_with("s3:ObjectCreated:")),
        "record eventName: {record}"
    );
    assert_eq!(record["s3"]["bucket"]["name"].as_str(), Some(bucket), "bucket in event: {record}");
    assert_conflicting_request_id_correlation(record, &put_request_id);
    assert_eq!(object["versionId"].as_str(), Some(put_version), "versionId in event: {object}");
    assert_eq!(
        trimmed_etag(object["eTag"].as_str()),
        trimmed_etag(put.e_tag()),
        "eTag in event: {object}"
    );

    // --- Multipart complete: ObjectCreated:CompleteMultipartUpload -----------
    let mp_key = "uploads/multi.dat";
    let created_mp = client.create_multipart_upload().bucket(bucket).key(mp_key).send().await?;
    let upload_id = created_mp.upload_id().ok_or("missing upload id")?;
    let part = client
        .upload_part()
        .bucket(bucket)
        .key(mp_key)
        .upload_id(upload_id)
        .part_number(1)
        .body(ByteStream::from_static(b"peri-1 multipart part body"))
        .send()
        .await?;
    let complete = client
        .complete_multipart_upload()
        .bucket(bucket)
        .key(mp_key)
        .upload_id(upload_id)
        .multipart_upload(
            CompletedMultipartUpload::builder()
                .parts(
                    CompletedPart::builder()
                        .part_number(1)
                        .e_tag(part.e_tag().unwrap_or_default())
                        .build(),
                )
                .build(),
        )
        .send()
        .await?;

    let mp_event = wait_for_event(&mut rx, mp_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
    let mp_record = &mp_event["Records"][0];
    assert_eq!(
        mp_event["EventName"].as_str(),
        Some("s3:ObjectCreated:CompleteMultipartUpload"),
        "multipart EventName: {mp_event}"
    );
    assert_eq!(mp_record["s3"]["bucket"]["name"].as_str(), Some(bucket));
    assert_eq!(
        trimmed_etag(mp_record["s3"]["object"]["eTag"].as_str()),
        trimmed_etag(complete.e_tag()),
        "multipart eTag in event: {mp_record}"
    );

    // --- Snowball extract: direct notification path keeps response correlation
    let snowball_key = "uploads/snowball.dat";
    let snowball_body = b"snowball notification body";
    let mut archive_builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
    let mut archive_header = tokio_tar::Header::new_gnu();
    archive_header.set_size(u64::try_from(snowball_body.len()).expect("snowball fixture length should fit in u64"));
    archive_header.set_mode(0o644);
    archive_header.set_cksum();
    archive_builder
        .append_data(&mut archive_header, snowball_key, Cursor::new(snowball_body))
        .await?;
    let archive = archive_builder.into_inner().await?.into_inner();
    let snowball = client
        .put_object()
        .bucket(bucket)
        .key("snowball-fixture.tar")
        .body(ByteStream::from(archive))
        .customize()
        .mutate_request(|request| {
            request.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
        })
        .send()
        .await?;
    let snowball_request_id = snowball.request_id().ok_or("Snowball response missing request ID")?;

    let snowball_event = wait_for_event(&mut rx, snowball_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
    let snowball_record = &snowball_event["Records"][0];
    assert_generated_request_id_correlation(snowball_record, snowball_request_id);

    // --- Filter: non-matching prefix and suffix must never be delivered ------
    let wrong_prefix = "logs/report.dat"; // right suffix, wrong prefix
    let wrong_suffix = "uploads/report.txt"; // right prefix, wrong suffix
    let sentinel = "uploads/keep.dat";
    for key in [wrong_prefix, wrong_suffix, sentinel] {
        client
            .put_object()
            .bucket(bucket)
            .key(key)
            .body(ByteStream::from_static(b"filter probe"))
            .send()
            .await?;
    }
    let collected = collect_until(&mut rx, sentinel, Duration::from_secs(20), Duration::from_secs(2)).await;
    let seen: Vec<String> = collected.iter().filter_map(event_key).collect();
    assert!(
        seen.iter().any(|k| k == sentinel),
        "matching sentinel {sentinel} was not delivered; saw {seen:?}"
    );
    assert!(
        !seen.iter().any(|k| k == wrong_prefix),
        "wrong-prefix key {wrong_prefix} bypassed the filter; saw {seen:?}"
    );
    assert!(
        !seen.iter().any(|k| k == wrong_suffix),
        "wrong-suffix key {wrong_suffix} bypassed the filter; saw {seen:?}"
    );

    // --- DeleteObjects: direct notification path keeps response correlation --
    let delete_many_key = "uploads/delete-many.dat";
    client
        .put_object()
        .bucket(bucket)
        .key(delete_many_key)
        .body(ByteStream::from_static(b"delete objects notification body"))
        .send()
        .await?;
    wait_for_event(&mut rx, delete_many_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;

    let delete_many = client
        .delete_objects()
        .bucket(bucket)
        .delete(
            Delete::builder()
                .objects(ObjectIdentifier::builder().key(delete_many_key).build()?)
                .build()?,
        )
        .send()
        .await?;
    let delete_many_request_id = delete_many.request_id().ok_or("DeleteObjects response missing request ID")?;
    let delete_many_event = wait_for_event(&mut rx, delete_many_key, "s3:ObjectRemoved:", Duration::from_secs(20)).await?;
    assert_generated_request_id_correlation(&delete_many_event["Records"][0], delete_many_request_id);

    // --- DeleteObject on a versioned bucket: delete-marker version ----------
    let delete = client.delete_object().bucket(bucket).key(put_key).send().await?;
    let removed = wait_for_event(&mut rx, put_key, "s3:ObjectRemoved:", Duration::from_secs(20)).await?;
    let removed_record = &removed["Records"][0];
    assert!(
        removed["EventName"]
            .as_str()
            .is_some_and(|n| n.starts_with("s3:ObjectRemoved:")),
        "delete EventName: {removed}"
    );
    if let Some(marker_version) = delete.version_id() {
        assert_eq!(
            removed_record["s3"]["object"]["versionId"].as_str(),
            Some(marker_version),
            "delete-marker versionId in event: {removed_record}"
        );
    }

    env.stop_server();
    handle.abort();
    Ok(())
}

/// An event queued while the target endpoint rejects delivery survives on the
/// durable store and is redelivered once the endpoint comes back.
#[tokio::test]
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
    init_logging();

    // Configure the target while its endpoint is reachable so activation and
    // ARN registration complete deterministically. Registering against a dead
    // endpoint stalls behind the reachability probe's timeout and flakes the
    // registration wait on loaded runners (observed on the merge run of
    // rustfs#4821).
    let listener = TcpListener::bind("0.0.0.0:0").await?;
    let port = listener.local_addr()?.port();
    let endpoint_ip = local_ip()?;
    let endpoint = format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port));
    let allowed_origin = endpoint_origin(&endpoint)?;
    let (setup_tx, _setup_rx) = mpsc::unbounded_channel();
    let setup_handle = serve_event_collector(listener, setup_tx);

    let mut env = RustFSTestEnvironment::new().await?;
    env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
        .await?;
    enable_notify_module(&env).await?;

    let bucket = "peri1-redeliver";
    let target = "peri1redeliver";
    let client = env.create_s3_client();
    client.create_bucket().bucket(bucket).send().await?;

    configure_webhook_target(&env, target, &endpoint).await?;
    wait_for_target_registered(&env, target).await?;
    put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;

    // Replace the healthy setup listener with one that rejects the first POST.
    // Waiting for that response below proves the queued event reached a failed
    // delivery attempt before the endpoint recovers.
    setup_handle.abort();
    let _ = setup_handle.await;

    let listener = TcpListener::bind(("0.0.0.0", port)).await?;
    let key = "uploads/redeliver.dat";
    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(ByteStream::from_static(b"queued while target rejects"))
        .send()
        .await?;

    let mut failure_handle = tokio::spawn(async move {
        loop {
            let (mut stream, _) = listener.accept().await?;
            let (method, _) = timeout(Duration::from_secs(5), read_http_message(&mut stream)).await??;
            if method == "HEAD" {
                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;
            }
            if method == "POST" {
                stream
                    .write_all(b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
                    .await?;
                stream.shutdown().await?;
                return Ok::<(), BoxError>(());
            }
        }
    });

    let rejected = match timeout(Duration::from_secs(20), &mut failure_handle).await {
        Ok(rejected) => rejected,
        Err(_) => {
            failure_handle.abort();
            let _ = failure_handle.await;
            return Err("webhook replay did not reach the rejecting endpoint".into());
        }
    };
    rejected??;

    // Bring the endpoint back on the same port; the replay worker rescans the
    // durable queue and delivers the retained event.
    let listener = TcpListener::bind(("0.0.0.0", port)).await?;
    let (tx, mut rx) = mpsc::unbounded_channel();
    let handle = serve_event_collector(listener, tx);

    let redelivered = wait_for_event(&mut rx, key, "s3:ObjectCreated:", Duration::from_secs(45)).await?;
    assert_eq!(
        redelivered["EventName"].as_str(),
        Some("s3:ObjectCreated:Put"),
        "redelivered EventName: {redelivered}"
    );
    assert_eq!(redelivered["Records"][0]["s3"]["bucket"]["name"].as_str(), Some(bucket));

    env.stop_server();
    handle.abort();
    Ok(())
}
