//! End-to-end tests for the inbound-email webhook receiver (v1.1.7).
//!
//! Gated on `DATABASE_URL` like `dispatcher_e2e.rs`: when unset the test
//! prints a notice and returns early so plain `cargo test` stays green.
//!
//! Covers the receiver's status-code matrix (202 / 401 / 404 / 422),
//! cross-app path isolation, HMAC verification (signed + unsigned
//! triggers), the dispatcher routing the `email` outbox row, and the
//! handler actually firing with `ctx.event.email` populated. The
//! "handler fired" observation uses the same KV-marker pattern as
//! `dispatcher_e2e.rs`.

#![allow(clippy::needless_pass_by_value)]

use std::time::Duration;

use axum_test::TestServer;
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
use sha2::Sha256;
use sqlx::PgPool;
use uuid::Uuid;

mod common;

/// Fixed master key so the receiver decrypts the inbound_secret the
/// admin endpoint encrypted (same key feeds build_app + the admin path).
fn master_key() -> picloud_shared::MasterKey {
    picloud_shared::MasterKey::from_bytes([0x42u8; 32])
}

async fn pool_or_skip() -> Option<PgPool> {
    common::test_pool("email_inbound").await
}

async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
    use picloud_manager_core::auth::hash_password;
    use picloud_shared::InstanceRole;

    let unique = format!("{suffix}-{}", Uuid::new_v4().simple());
    let auth = picloud::AuthDeps::from_pool(pool.clone());
    let username = format!("eml-{unique}");
    let hash = hash_password("pw").expect("hash");
    auth.users
        .create(&username, &hash, InstanceRole::Owner, None)
        .await
        .expect("seed admin");

    let app = picloud::build_app(pool, auth, master_key())
        .await
        .expect("build_app");
    let mut server = TestServer::new(app).expect("TestServer");
    let resp = server
        .post("/api/v1/admin/auth/login")
        .json(&json!({ "username": username, "password": "pw" }))
        .await;
    resp.assert_status_ok();
    let token = resp.json::<Value>()["token"]
        .as_str()
        .expect("login token")
        .to_string();
    server.add_header("authorization", format!("Bearer {token}"));

    let slug = format!("eml-{unique}");
    let created: Value = server
        .post("/api/v1/admin/apps")
        .json(&json!({ "slug": slug, "name": slug }))
        .await
        .json();
    let app_id = created["id"].as_str().expect("app id").to_string();
    (server, app_id)
}

async fn create_script(server: &TestServer, app_id: &str, name: &str, source: &str) -> String {
    let created: Value = server
        .post("/api/v1/admin/scripts")
        .json(&json!({ "app_id": app_id, "name": name, "source": source }))
        .await
        .json();
    created["id"].as_str().expect("script id").to_string()
}

const MARKER_HANDLER: &str = r#"
    let e = ctx.event;
    kv::collection("e2e_markers").set("marker", e);
    #{ ok: true }
"#;

async fn create_email_trigger(
    server: &TestServer,
    app_id: &str,
    script_id: &str,
    secret: Option<&str>,
) -> String {
    let created: Value = server
        .post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
        .json(&json!({ "script_id": script_id, "inbound_secret": secret }))
        .await
        .json();
    created["id"].as_str().expect("trigger id").to_string()
}

fn sign(secret: &str, ts: i64, body: &str) -> String {
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
    mac.update(ts.to_string().as_bytes());
    mac.update(b".");
    mac.update(body.as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

fn now_ts() -> i64 {
    chrono::Utc::now().timestamp()
}

async fn poll_marker(pool: &PgPool, app_id: &str) -> Option<Value> {
    let app_uuid = Uuid::parse_str(app_id).expect("app uuid");
    for _ in 0..100 {
        let row: Option<(Value,)> = sqlx::query_as(
            "SELECT value FROM kv_entries \
             WHERE app_id = $1 AND collection = 'e2e_markers' AND key = 'marker'",
        )
        .bind(app_uuid)
        .fetch_optional(pool)
        .await
        .expect("query marker");
        if let Some((value,)) = row {
            return Some(value);
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    None
}

const BODY: &str = r#"{"from":"sender@external.com","to":["alice@myapp.com"],"cc":["bob@myapp.com"],"subject":"Re: question","text":"hello there","message_id":"<abc@external.com>"}"#;

#[tokio::test]
async fn signed_post_accepts_and_fires_handler() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool.clone(), "signed").await;
    let handler = create_script(&server, &app_id, "eml-handler", MARKER_HANDLER).await;
    let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;

    let ts = now_ts();
    let sig = sign("topsecret", ts, BODY);
    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
        .add_header("x-picloud-signature", sig)
        .add_header("x-picloud-timestamp", ts.to_string())
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::ACCEPTED);

    // Outbox row landed with source_kind = 'email'.
    let app_uuid = Uuid::parse_str(&app_id).unwrap();
    // The dispatcher deletes the row after delivery; instead assert the
    // handler fired (which proves the email row was dispatched).
    let event = poll_marker(&pool, &app_id).await.expect("handler fired");
    assert_eq!(event["source"], "email");
    assert_eq!(event["op"], "receive");
    assert_eq!(event["email"]["from"], "sender@external.com");
    assert_eq!(event["email"]["to"][0], "alice@myapp.com");
    assert_eq!(event["email"]["cc"][0], "bob@myapp.com");
    assert_eq!(event["email"]["subject"], "Re: question");
    assert_eq!(event["email"]["text"], "hello there");
    assert_eq!(event["email"]["message_id"], "<abc@external.com>");
    let _ = app_uuid;
}

#[tokio::test]
async fn missing_signature_is_401_when_secret_configured() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "nosig").await;
    let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
    let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;

    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn wrong_signature_is_401() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "wrongsig").await;
    let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
    let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;

    let ts = now_ts();
    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
        .add_header("x-picloud-signature", sign("WRONG", ts, BODY))
        .add_header("x-picloud-timestamp", ts.to_string())
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn create_without_inbound_secret_is_rejected() {
    // Audit 2026-06-11 H-B2 — email triggers must be created with an
    // HMAC secret. The previous behavior accepted None and silently
    // turned off signature verification at runtime.
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "no-secret").await;
    let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
    let resp = server
        .post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
        .json(&json!({ "script_id": handler, "inbound_secret": null }))
        .await;
    resp.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);

    let resp = server
        .post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
        .json(&json!({ "script_id": handler, "inbound_secret": "" }))
        .await;
    resp.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);

    let resp = server
        .post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
        .json(&json!({ "script_id": handler, "inbound_secret": "   " }))
        .await;
    resp.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}

#[tokio::test]
async fn unknown_trigger_is_404() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "missing").await;
    let missing = Uuid::new_v4();
    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{missing}"))
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn wrong_kind_trigger_is_404() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "wrongkind").await;
    let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
    // A KV trigger — not an email trigger.
    let kv_trigger: Value = server
        .post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv"))
        .json(&json!({ "script_id": handler, "collection_glob": "*" }))
        .await
        .json();
    let kv_id = kv_trigger["id"].as_str().unwrap();
    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{kv_id}"))
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn malformed_body_is_422() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let (server, app_id) = server_for(pool, "malformed").await;
    let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
    let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
    // Sign the malformed body so we reach the parse step (audit
    // 2026-06-11 H-B2 — HMAC is now mandatory).
    let bad_body = "not json at all";
    let ts = now_ts();
    let sig = sign("topsecret", ts, bad_body);
    server
        .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
        .add_header("x-picloud-signature", sig)
        .add_header("x-picloud-timestamp", ts.to_string())
        .text(bad_body)
        .await
        .assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}

#[tokio::test]
async fn cross_app_path_is_404() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    // Two apps under the same server. A trigger created in app B must
    // not be reachable via app A's path segment.
    let (server, app_a) = server_for(pool.clone(), "xa").await;
    let app_b: Value = server
        .post("/api/v1/admin/apps")
        .json(&json!({ "slug": format!("xb-{}", Uuid::new_v4().simple()), "name": "xb" }))
        .await
        .json();
    let app_b_id = app_b["id"].as_str().unwrap().to_string();
    let handler_b = create_script(&server, &app_b_id, "hb", MARKER_HANDLER).await;
    // Audit 2026-06-11 H-B2 — secret required on create; the path-
    // app-id mismatch fires before HMAC verification anyway.
    let trigger_b = create_email_trigger(&server, &app_b_id, &handler_b, Some("s")).await;

    // POST to app A's path with app B's trigger id → 404 (path-bound).
    server
        .post(&format!("/api/v1/email-inbound/{app_a}/{trigger_b}"))
        .text(BODY)
        .await
        .assert_status(axum::http::StatusCode::NOT_FOUND);
}
