/*!
 * End-to-end integration tests for Windmill trigger listeners.
 *
 * Each test is `#[ignore]` because it requires a running external service
 * (MQTT broker, NATS server, Kafka broker, etc.). See individual test doc
 * comments for setup instructions.
 *
 * Quick start — use the helper scripts in `tests/fixtures/`:
 * ```bash
 * ./tests/fixtures/start_all_triggers.sh          # start all services
 * ./tests/fixtures/start_all_triggers.sh oss      # start OSS services only
 * ./tests/fixtures/start_all_triggers.sh stop     # tear down everything
 * ```
 *
 * The general pattern:
 * 1. Insert a test script + trigger row + resource into the DB
 * 2. Start the API server with listeners enabled (server_mode=true)
 * 3. Connect to the external service and send a test message
 * 4. Poll `v2_job` for a job matching the trigger path + trigger_kind
 * 5. Verify the args shape/content
 */

use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;

use windmill_test_utils::*;

// ============================================================================
// Helpers
// ============================================================================

/// Row shape for polling v2_job.
#[derive(Debug)]
#[allow(dead_code)]
struct TriggerJobRow {
    id: uuid::Uuid,
    runnable_path: Option<String>,
    trigger_kind: Option<String>,
    args: Option<sqlx::types::Json<serde_json::Value>>,
}

/// Poll `v2_job` every 500ms for up to `timeout` for a job whose
/// `runnable_path` and `trigger_kind` match the expected values.
async fn poll_for_trigger_job(
    db: &Pool<Postgres>,
    script_path: &str,
    trigger_kind: &str,
    timeout: Duration,
) -> anyhow::Result<TriggerJobRow> {
    let deadline = tokio::time::Instant::now() + timeout;
    loop {
        let row = sqlx::query_as!(
            TriggerJobRow,
            r#"
            SELECT id, runnable_path, trigger_kind AS "trigger_kind: String",
                   args AS "args: sqlx::types::Json<serde_json::Value>"
            FROM v2_job
            WHERE runnable_path = $1
              AND trigger_kind = $2::job_trigger_kind
            ORDER BY created_at DESC
            LIMIT 1
            "#,
            script_path,
            trigger_kind as _,
        )
        .fetch_optional(db)
        .await?;

        if let Some(job) = row {
            return Ok(job);
        }

        if tokio::time::Instant::now() >= deadline {
            anyhow::bail!(
                "timed out waiting for trigger job (script_path={}, trigger_kind={})",
                script_path,
                trigger_kind
            );
        }

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

/// Insert a minimal test script row that trigger listeners can reference.
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
    let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
    sqlx::query(
        "INSERT INTO script (workspace_id, hash, path, summary, description, content,
                  created_by, language, kind, lock)
         VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
                  'test-user', 'python3', 'script', '')",
    )
    .bind(hash)
    .bind(path)
    .execute(db)
    .await?;
    Ok(hash)
}

/// Insert a resource row for triggers that resolve connection details from the
/// `resource` table.
async fn insert_resource(
    db: &Pool<Postgres>,
    path: &str,
    resource_type: &str,
    value: serde_json::Value,
) -> anyhow::Result<()> {
    sqlx::query(
        "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
         VALUES ('test-workspace', $1, $2::jsonb, $3, '{}'::jsonb, 'test-user')",
    )
    .bind(path)
    .bind(value)
    .bind(resource_type)
    .execute(db)
    .await?;
    Ok(())
}

// ============================================================================
// MQTT Trigger E2E
// ============================================================================

/// End-to-end test for MQTT trigger.
///
/// Requires a running MQTT broker. Setup:
/// ```bash
/// ./tests/fixtures/start_mqtt.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_mqtt_e2e --features mqtt_trigger \
///     -- --ignored --nocapture
/// ```
#[ignore = "requires running MQTT broker on localhost:1883"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mqtt_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/mqtt_e2e_handler";
    insert_test_script(&db, script_path).await?;

    insert_resource(
        &db,
        "u/test-user/mqtt_res",
        "mqtt",
        json!({
            "broker": "localhost",
            "port": 1883
        }),
    )
    .await?;

    sqlx::query(
        r#"
        INSERT INTO mqtt_trigger (
            path, mqtt_resource_path, subscribe_topics, client_version,
            script_path, is_flow, workspace_id, edited_by, permissioned_as
        )
        VALUES ($1, $2, ARRAY[$3::jsonb], $4::mqtt_client_version, $5, $6, $7, $8, $9)
        "#,
    )
    .bind("f/test/mqtt_e2e_trigger")
    .bind("u/test-user/mqtt_res")
    .bind(json!({"topic": "windmill/test/e2e", "qos": "qos0"}))
    .bind("v5")
    .bind(script_path)
    .bind(false)
    .bind("test-workspace")
    .bind("test-user")
    .bind("u/test-user")
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(3)).await;

    // Publish a message using rumqttc
    let mut mqtt_opts = rumqttc::MqttOptions::new("windmill-e2e-test", "localhost", 1883);
    mqtt_opts.set_keep_alive(Duration::from_secs(5));
    let (client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_opts, 10);

    // Drive the event loop in the background
    let el_handle = tokio::spawn(async move {
        loop {
            match eventloop.poll().await {
                Ok(_) => {}
                Err(_) => break,
            }
        }
    });

    tokio::time::sleep(Duration::from_millis(500)).await;
    client
        .publish(
            "windmill/test/e2e",
            rumqttc::QoS::AtLeastOnce,
            false,
            b"hello from e2e test".to_vec(),
        )
        .await?;

    let job = poll_for_trigger_job(&db, script_path, "mqtt", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    client.disconnect().await.ok();
    el_handle.abort();

    Ok(())
}

// ============================================================================
// WebSocket Trigger E2E
// ============================================================================

/// End-to-end test for WebSocket trigger.
///
/// Requires a WebSocket echo server. Setup:
/// ```bash
/// ./tests/fixtures/start_websocket.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_websocket_e2e --features websocket \
///     -- --ignored --nocapture
/// ```
#[ignore = "requires running WebSocket echo server on localhost:8765"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_websocket_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/ws_e2e_handler";
    insert_test_script(&db, script_path).await?;

    sqlx::query!(
        r#"
        INSERT INTO websocket_trigger (
            path, url, script_path, is_flow, workspace_id,
            edited_by, permissioned_as, initial_messages
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
        "#,
        "f/test/ws_e2e_trigger",
        "ws://localhost:8765",
        script_path,
        false,
        "test-workspace",
        "test-user",
        "u/test-user",
        &[json!({"type": "RawMessage", "content": "hello from e2e test"})]
            as &[serde_json::Value],
    )
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;

    // The WebSocket trigger connects to the server and sends initial_messages,
    // and each received message triggers a job.
    let job = poll_for_trigger_job(&db, script_path, "websocket", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}

// ============================================================================
// Postgres Trigger E2E
// ============================================================================

/// End-to-end test for Postgres trigger (logical replication).
///
/// Requires PostgreSQL with `wal_level=logical`. Setup:
/// ```bash
/// ./tests/fixtures/start_postgres_replication.sh
/// ```
/// (The script checks wal_level and creates the table/publication/slot in the
/// main DB. This test re-creates them in its isolated sqlx::test database.)
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_postgres_e2e --features postgres_trigger \
///     -- --ignored --nocapture
/// ```
#[ignore = "requires PostgreSQL with wal_level=logical"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_postgres_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/pg_e2e_handler";
    insert_test_script(&db, script_path).await?;

    // Create the tracked table + publication + replication slot inside the
    // isolated test database (sqlx::test gives us a fresh DB each run).
    // Replication slots are server-wide so we use a random suffix.
    let suffix: u32 = rand::random();
    let slot_name = format!("test_e2e_slot_{suffix}");
    let pub_name = format!("test_e2e_pub_{suffix}");

    sqlx::query("CREATE TABLE test_trigger_table (id serial PRIMARY KEY, data text)")
        .execute(&db)
        .await?;
    sqlx::query(&format!(
        "CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table"
    ))
    .execute(&db)
    .await?;
    sqlx::query(&format!(
        "SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')"
    ))
    .execute(&db)
    .await?;

    // Extract the test DB name from the pool so the resource points here,
    // not at the main windmill database.
    let test_db_name: String = sqlx::query_scalar("SELECT current_database()")
        .fetch_one(&db)
        .await?;

    insert_resource(
        &db,
        "u/test-user/pg_res",
        "postgresql",
        json!({
            "user": "postgres",
            "password": "changeme",
            "host": "localhost",
            "port": 5432,
            "dbname": test_db_name,
            "sslmode": "disable"
        }),
    )
    .await?;

    sqlx::query(
        r#"
        INSERT INTO postgres_trigger (
            path, script_path, is_flow, workspace_id, edited_by, permissioned_as,
            postgres_resource_path, replication_slot_name, publication_name
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
        "#,
    )
    .bind("f/test/pg_e2e_trigger")
    .bind(script_path)
    .bind(false)
    .bind("test-workspace")
    .bind("test-user")
    .bind("u/test-user")
    .bind("u/test-user/pg_res")
    .bind(&slot_name)
    .bind(&pub_name)
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(3)).await;

    // Insert a row into the tracked table to trigger a change event
    sqlx::query("INSERT INTO test_trigger_table (data) VALUES ('e2e test data')")
        .execute(&db)
        .await?;

    let job = poll_for_trigger_job(&db, script_path, "postgres", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}

/// Regression test for the replication-slot LSN freeze bug.
///
/// Before the fix the Postgres trigger consumer only sent a standby status
/// update in response to a `PrimaryKeepAlive` with `reply=true` — never on a
/// timer and never after processing `XLogData`. The bound jobs ran fine but
/// the slot's `confirmed_flush_lsn`/`restart_lsn` never advanced, so retained
/// WAL grew unbounded until it exceeded `max_slot_wal_keep_size`.
///
/// This asserts the slot's `confirmed_flush_lsn` advances to at least the WAL
/// position recorded *before* the change events, proving feedback is sent.
/// Against the pre-fix code this loop times out (LSN frozen at slot creation);
/// with the fix the periodic status update advances it within ~10s.
///
/// Requires PostgreSQL with `wal_level=logical` (see `test_postgres_e2e`).
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_postgres_slot_lsn_advances \
///     --features postgres_trigger -- --ignored --nocapture
/// ```
#[ignore = "requires PostgreSQL with wal_level=logical"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_postgres_slot_lsn_advances(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/pg_lsn_handler";
    insert_test_script(&db, script_path).await?;

    // Replication slots are server-wide so use a random suffix.
    let suffix: u32 = rand::random();
    let slot_name = format!("test_lsn_slot_{suffix}");
    let pub_name = format!("test_lsn_pub_{suffix}");

    sqlx::query("CREATE TABLE lsn_trigger_table (id serial PRIMARY KEY, data text)")
        .execute(&db)
        .await?;
    sqlx::query(&format!(
        "CREATE PUBLICATION {pub_name} FOR TABLE lsn_trigger_table"
    ))
    .execute(&db)
    .await?;
    sqlx::query(&format!(
        "SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')"
    ))
    .execute(&db)
    .await?;

    let test_db_name: String = sqlx::query_scalar("SELECT current_database()")
        .fetch_one(&db)
        .await?;

    insert_resource(
        &db,
        "u/test-user/pg_lsn_res",
        "postgresql",
        json!({
            "user": "postgres",
            "password": "changeme",
            "host": "localhost",
            "port": 5432,
            "dbname": test_db_name,
            "sslmode": "disable"
        }),
    )
    .await?;

    sqlx::query(
        r#"
        INSERT INTO postgres_trigger (
            path, script_path, is_flow, workspace_id, edited_by, permissioned_as,
            postgres_resource_path, replication_slot_name, publication_name
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
        "#,
    )
    .bind("f/test/pg_lsn_trigger")
    .bind(script_path)
    .bind(false)
    .bind("test-workspace")
    .bind("test-user")
    .bind("u/test-user")
    .bind("u/test-user/pg_lsn_res")
    .bind(&slot_name)
    .bind(&pub_name)
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    // Give the listener time to open the replication stream.
    tokio::time::sleep(Duration::from_secs(3)).await;

    // WAL position before any change events. A correctly behaving consumer
    // must report a flushed LSN at or beyond this once it has processed the
    // change traffic below.
    let before_lsn: String = sqlx::query_scalar("SELECT pg_current_wal_lsn()::text")
        .fetch_one(&db)
        .await?;

    // Sustained change traffic — this is what makes the bug bite in
    // production. While data keeps flowing, Postgres does not volunteer a
    // reply-requested keepalive until it is about to hit `wal_sender_timeout`
    // (default 60s, so ~30s of silence). The pre-fix consumer only ever
    // replied to those keepalives, so under continuous load the slot stayed
    // frozen. The fix sends a standby status update every 10s regardless of
    // traffic, so the slot advances well before the keepalive path would.
    let inserter_db = db.clone();
    let inserter = tokio::spawn(async move {
        let mut i = 0;
        loop {
            let _ = sqlx::query("INSERT INTO lsn_trigger_table (data) VALUES ($1)")
                .bind(format!("row {i}"))
                .execute(&inserter_db)
                .await;
            i += 1;
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    });

    // Sanity: the bound job still runs (this passed even with the bug).
    let job = poll_for_trigger_job(&db, script_path, "postgres", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    // Post-fix advances within ~10s (the status-update interval). Pre-fix
    // cannot advance before the first forced reply-keepalive (~30s with the
    // default 60s wal_sender_timeout), so a 20s deadline cleanly separates the
    // two while leaving generous margin on both sides.
    let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
    let advanced = loop {
        let advanced: Option<bool> = sqlx::query_scalar(
            r#"
            SELECT confirmed_flush_lsn >= $1::pg_lsn
            FROM pg_replication_slots
            WHERE slot_name = $2
            "#,
        )
        .bind(&before_lsn)
        .bind(&slot_name)
        .fetch_one(&db)
        .await?;

        if advanced == Some(true) {
            break true;
        }

        if tokio::time::Instant::now() >= deadline {
            break false;
        }

        tokio::time::sleep(Duration::from_secs(1)).await;
    };

    inserter.abort();

    if !advanced {
        let current: Option<String> = sqlx::query_scalar(
            "SELECT confirmed_flush_lsn::text FROM pg_replication_slots WHERE slot_name = $1",
        )
        .bind(&slot_name)
        .fetch_one(&db)
        .await?;
        panic!(
            "replication slot {slot_name} confirmed_flush_lsn did not advance within 20s: \
             before={before_lsn}, current={current:?} — WAL retention would grow unbounded"
        );
    }

    Ok(())
}

// ============================================================================
// Kafka Trigger E2E (Enterprise)
// ============================================================================

/// End-to-end test for Kafka trigger (Enterprise only).
///
/// Requires a running Kafka broker with the test topic. Setup:
/// ```bash
/// ./tests/fixtures/start_kafka.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_kafka_e2e \
///     --features kafka,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires running Kafka broker on localhost:9092"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_kafka_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/kafka_e2e_handler";
    insert_test_script(&db, script_path).await?;

    insert_resource(
        &db,
        "u/test-user/kafka_res",
        "kafka",
        json!({
            "brokers": ["localhost:9092"],
            "security": { "label": "PLAINTEXT" }
        }),
    )
    .await?;

    sqlx::query!(
        r#"
        INSERT INTO kafka_trigger (
            path, kafka_resource_path, topics, group_id,
            script_path, is_flow, workspace_id, edited_by, permissioned_as
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
        "#,
        "f/test/kafka_e2e_trigger",
        "u/test-user/kafka_res",
        &["windmill-e2e-test"] as &[&str],
        "windmill-e2e-test-group",
        script_path,
        false,
        "test-workspace",
        "test-user",
        "u/test-user",
    )
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(5)).await;

    // Produce messages using rdkafka. The consumer starts with auto.offset.reset=latest
    // and needs time for group rebalance, so we send repeatedly until a job appears.
    use rdkafka::config::ClientConfig;
    use rdkafka::producer::{FutureProducer, FutureRecord};

    let producer: FutureProducer = ClientConfig::new()
        .set("bootstrap.servers", "localhost:9092")
        .create()?;

    let db2 = db.clone();
    let produce_handle = tokio::spawn(async move {
        for _ in 0..30 {
            let _ = producer
                .send(
                    FutureRecord::to("windmill-e2e-test")
                        .payload("hello from kafka e2e test")
                        .key("test-key"),
                    Duration::from_secs(5),
                )
                .await;
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    });

    let job = poll_for_trigger_job(&db2, script_path, "kafka", Duration::from_secs(30)).await?;
    produce_handle.abort();
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}

// ============================================================================
// NATS Trigger E2E (Enterprise)
// ============================================================================

/// End-to-end test for NATS trigger (Enterprise only).
///
/// Requires a running NATS server. Setup:
/// ```bash
/// ./tests/fixtures/start_nats.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_nats_e2e \
///     --features nats,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires running NATS server on localhost:4222"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_nats_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/nats_e2e_handler";
    insert_test_script(&db, script_path).await?;

    insert_resource(
        &db,
        "u/test-user/nats_res",
        "nats",
        json!({
            "servers": ["nats://localhost:4222"],
            "auth": { "label": "NO_AUTH" },
            "require_tls": false
        }),
    )
    .await?;

    sqlx::query!(
        r#"
        INSERT INTO nats_trigger (
            path, nats_resource_path, subjects, script_path,
            is_flow, workspace_id, edited_by, permissioned_as, use_jetstream
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
        "#,
        "f/test/nats_e2e_trigger",
        "u/test-user/nats_res",
        &["windmill.e2e.test"] as &[&str],
        script_path,
        false,
        "test-workspace",
        "test-user",
        "u/test-user",
        false,
    )
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(3)).await;

    // Publish a message using async-nats
    let nats_client = async_nats::connect("localhost:4222").await?;
    nats_client
        .publish("windmill.e2e.test", "hello from nats e2e test".into())
        .await?;
    nats_client.flush().await?;

    let job = poll_for_trigger_job(&db, script_path, "nats", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}

// ============================================================================
// SQS Trigger E2E (Enterprise)
// ============================================================================

/// End-to-end test for SQS trigger (Enterprise only).
///
/// Requires LocalStack with the test queue. Setup:
/// ```bash
/// ./tests/fixtures/start_sqs.sh
/// ```
///
/// Run:
/// ```bash
/// AWS_ENDPOINT_URL=http://localhost:4566 \
/// cargo test --test trigger_e2e test_sqs_e2e \
///     --features sqs_trigger,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires LocalStack SQS on localhost:4566"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_sqs_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    // The SQS listener uses aws_config which respects AWS_ENDPOINT_URL for LocalStack.
    std::env::set_var("AWS_ENDPOINT_URL", "http://localhost:4566");

    let script_path = "f/test/sqs_e2e_handler";
    insert_test_script(&db, script_path).await?;

    insert_resource(
        &db,
        "u/test-user/aws_res",
        "aws",
        json!({
            "awsAccessKeyId": "test",
            "awsSecretAccessKey": "test",
            "region": "us-east-1"
        }),
    )
    .await?;

    sqlx::query!(
        r#"
        INSERT INTO sqs_trigger (
            path, queue_url, aws_resource_path, script_path,
            is_flow, workspace_id, edited_by, permissioned_as
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
        "#,
        "f/test/sqs_e2e_trigger",
        "http://localhost:4566/000000000000/windmill-e2e-test",
        "u/test-user/aws_res",
        script_path,
        false,
        "test-workspace",
        "test-user",
        "u/test-user",
    )
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(3)).await;

    // Send a message using aws-sdk-sqs
    let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
        .endpoint_url("http://localhost:4566")
        .region(aws_config::Region::new("us-east-1"))
        .credentials_provider(aws_credential_types::Credentials::new(
            "test", "test", None, None, "test",
        ))
        .load()
        .await;
    let sqs_client = aws_sdk_sqs::Client::new(&config);

    sqs_client
        .send_message()
        .queue_url("http://localhost:4566/000000000000/windmill-e2e-test")
        .message_body("hello from sqs e2e test")
        .send()
        .await?;

    let job = poll_for_trigger_job(&db, script_path, "sqs", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}

// ============================================================================
// GCP Pub/Sub Trigger E2E (Enterprise)
// ============================================================================

/// End-to-end test for GCP Pub/Sub trigger (Enterprise only).
///
/// Requires the GCP Pub/Sub emulator with test topic/subscription. Setup:
/// ```bash
/// ./tests/fixtures/start_gcp_pubsub.sh
/// ```
///
/// Run:
/// ```bash
/// PUBSUB_EMULATOR_HOST=localhost:8085 \
/// cargo test --test trigger_e2e test_gcp_e2e \
///     --features gcp_trigger,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires GCP Pub/Sub emulator on localhost:8085"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let script_path = "f/test/gcp_e2e_handler";
    insert_test_script(&db, script_path).await?;

    // The GCP emulator doesn't require real credentials, but the resource
    // row must still exist for the listener to resolve it.
    // The private_key must use literal \n (backslash-n) as in real GCP service
    // account JSON files. The trigger code re-parses it through serde_json to
    // convert those escape sequences to actual newlines.
    insert_resource(
        &db,
        "u/test-user/gcp_res",
        "google",
        json!({
            "project_id": "test-project",
            "private_key_id": "test",
            "private_key": "-----BEGIN RSA PRIVATE KEY-----\\nMIIBogIBAAJBALRiMLAH\\n-----END RSA PRIVATE KEY-----\\n",
            "client_email": "test@test-project.iam.gserviceaccount.com",
            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
            "token_uri": "https://oauth2.googleapis.com/token",
            "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs"
        }),
    )
    .await?;

    sqlx::query(
        r#"
        INSERT INTO gcp_trigger (
            path, gcp_resource_path, topic_id, subscription_id,
            delivery_type, subscription_mode, script_path, is_flow,
            workspace_id, edited_by, permissioned_as
        )
        VALUES ($1, $2, $3, $4, $5::delivery_mode, $6::gcp_subscription_mode, $7, $8, $9, $10, $11)
        "#,
    )
    .bind("f/test/gcp_e2e_trigger")
    .bind("u/test-user/gcp_res")
    .bind("windmill-e2e-test")
    .bind("windmill-e2e-sub")
    .bind("pull")
    .bind("existing")
    .bind(script_path)
    .bind(false)
    .bind("test-workspace")
    .bind("test-user")
    .bind("u/test-user")
    .execute(&db)
    .await?;

    let _server = ApiServer::start_with_listeners(db.clone()).await?;
    tokio::time::sleep(Duration::from_secs(3)).await;

    // Publish a message to the emulator via HTTP
    let client = reqwest::Client::new();
    let emulator_host =
        std::env::var("PUBSUB_EMULATOR_HOST").unwrap_or_else(|_| "localhost:8085".to_string());
    // The google-cloud-pubsub crate uses "local-project" as the default project ID
    // when PUBSUB_EMULATOR_HOST is set, so we must publish to that project's topic.
    let publish_url = format!(
        "http://{}/v1/projects/local-project/topics/windmill-e2e-test:publish",
        emulator_host
    );

    let message_data = base64::Engine::encode(
        &base64::engine::general_purpose::STANDARD,
        "hello from gcp e2e test",
    );
    client
        .post(&publish_url)
        .json(&json!({
            "messages": [{ "data": message_data }]
        }))
        .send()
        .await?;

    let job = poll_for_trigger_job(&db, script_path, "gcp", Duration::from_secs(30)).await?;
    assert!(job.args.is_some(), "job should have args");

    Ok(())
}
