//! `POST /api/v1/email-inbound/{app_id}/{trigger_id}` — the inbound-email
//! webhook receiver (v1.1.7).
//!
//! A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs a
//! normalized JSON message here; the receiver verifies the optional HMAC
//! signature, builds a `TriggerEvent::Email`, and enqueues an outbox row
//! the dispatcher picks up like any other async trigger.
//!
//! This is a PUBLIC endpoint (no admin auth) — the trigger URL itself,
//! plus the per-trigger HMAC secret, are the security boundary. It is
//! mounted OUTSIDE the `require_authenticated` layer.
//!
//! Status codes:
//!   * 202 — accepted + enqueued
//!   * 401 — HMAC required but missing/invalid
//!   * 404 — trigger missing, disabled, not `kind=email`, or app mismatch
//!   * 422 — body is not the expected JSON shape
//!
//! Only the generic provider-agnostic JSON shape is accepted in v1.1.7
//! (see [`InboundPayload`]); provider-specific unmarshallers are v1.2.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::post;
use axum::Router;
use hmac::{Hmac, Mac};
use picloud_shared::{AppId, MasterKey, TriggerEvent, TriggerId};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};

use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
use crate::secrets_service::{open_email, SecretOwner};
use crate::trigger_repo::TriggerRepo;

type HmacSha256 = Hmac<Sha256>;

/// Header the provider's HMAC signature is read from. Signature is
/// the lowercase hex of `HMAC-SHA256(inbound_secret, timestamp || "." || raw_body)`.
const SIGNATURE_HEADER: &str = "x-picloud-signature";

/// Header the provider posts the request unix-seconds timestamp on. The
/// timestamp is bound into the signature input (F-S-010) so captured
/// POSTs can't be replayed indefinitely.
const TIMESTAMP_HEADER: &str = "x-picloud-timestamp";

/// Reject signatures whose timestamp is further than this from now. 5 min
/// gives ample provider+network slack while bounding the replay window.
const SIGNATURE_TIMESTAMP_TOLERANCE_SECS: i64 = 300;

/// How long an accepted (timestamp, body-hash) pair is remembered to
/// catch within-window replays. Slightly longer than tolerance so a
/// replay near the edge of the window is still seen as duplicate.
const NONCE_DEDUP_TTL_SECS: u64 = 600;

/// Tiny in-memory dedup for accepted `(timestamp, sha256(body))` pairs.
/// Bounded by [`NONCE_DEDUP_TTL_SECS`] — entries older than the TTL are
/// pruned on each `record_or_reject` call.
#[derive(Default)]
pub struct InboundNonceDedup {
    inner: Mutex<HashMap<(i64, [u8; 32]), Instant>>,
}

impl InboundNonceDedup {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Return `Ok(())` if the pair was new; `Err(())` if already seen
    /// within the TTL window.
    fn record_or_reject(&self, ts: i64, body_hash: [u8; 32]) -> Result<(), ()> {
        let now = Instant::now();
        let ttl = std::time::Duration::from_secs(NONCE_DEDUP_TTL_SECS);
        let mut map = self.inner.lock().expect("nonce dedup mutex poisoned");
        map.retain(|_, recorded_at| now.duration_since(*recorded_at) < ttl);
        if map.insert((ts, body_hash), now).is_some() {
            return Err(());
        }
        Ok(())
    }
}

/// Audit 2026-06-11 H-B2 — per-`(app_id, trigger_id)` bad-signature
/// limiter. After [`BAD_SIG_BURST`] failed verifies within
/// [`BAD_SIG_WINDOW`], subsequent attempts return 429 with a retry-
/// after hint rather than 401, so the dispatcher's Argon2-equivalent
/// (HMAC verify + JSON parse + outbox insert) can't be hammered.
const BAD_SIG_BURST: u32 = 10;
const BAD_SIG_WINDOW: Duration = Duration::from_secs(60);

#[derive(Debug, Clone, Copy)]
struct BadSigBucket {
    window_started_at: Instant,
    count: u32,
}

#[derive(Default)]
pub struct BadSignatureLimiter {
    inner: Mutex<HashMap<(AppId, TriggerId), BadSigBucket>>,
}

impl BadSignatureLimiter {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Check whether this trigger is currently locked out. Does NOT
    /// increment — that happens in [`Self::record_failure`].
    fn check(&self, app_id: AppId, trigger_id: TriggerId) -> Result<(), Duration> {
        let now = Instant::now();
        let mut map = self.inner.lock().expect("bad-sig limiter poisoned");
        // Lazy sweep when crossing soft cap.
        if map.len() > 4096 {
            map.retain(|_, b| now.duration_since(b.window_started_at) < BAD_SIG_WINDOW);
        }
        let Some(b) = map.get_mut(&(app_id, trigger_id)) else {
            return Ok(());
        };
        if now.duration_since(b.window_started_at) >= BAD_SIG_WINDOW {
            *b = BadSigBucket {
                window_started_at: now,
                count: 0,
            };
        }
        if b.count >= BAD_SIG_BURST {
            return Err(BAD_SIG_WINDOW.saturating_sub(now.duration_since(b.window_started_at)));
        }
        Ok(())
    }

    /// Record a failed signature verify.
    fn record_failure(&self, app_id: AppId, trigger_id: TriggerId) {
        let now = Instant::now();
        let mut map = self.inner.lock().expect("bad-sig limiter poisoned");
        let b = map.entry((app_id, trigger_id)).or_insert(BadSigBucket {
            window_started_at: now,
            count: 0,
        });
        if now.duration_since(b.window_started_at) >= BAD_SIG_WINDOW {
            *b = BadSigBucket {
                window_started_at: now,
                count: 0,
            };
        }
        b.count = b.count.saturating_add(1);
    }
}

#[derive(Clone)]
pub struct EmailInboundState {
    pub triggers: Arc<dyn TriggerRepo>,
    pub outbox: Arc<dyn OutboxRepo>,
    pub master_key: MasterKey,
    /// F-S-010: replay-dedup for accepted signed payloads. Process-local
    /// — cluster mode (v1.3+) will need a shared store. Per-process is
    /// fine for single-node MVP and dev.
    pub nonce_dedup: Arc<InboundNonceDedup>,
    /// Audit 2026-06-11 H-B2 — bad-signature lockout per
    /// `(app_id, trigger_id)`. Process-local; cluster needs a shared
    /// store but v1.1.x is single-node.
    pub bad_sig_limiter: Arc<BadSignatureLimiter>,
}

/// Per-request body cap for the inbound webhook. Inbound email events
/// are small JSON envelopes (from/to/subject/body); 1 MiB is generous.
/// Audit 2026-06-11 (H-1) — this public, unauthenticated endpoint
/// otherwise rode Axum's 2 MB extractor default; pin it explicitly and
/// tighter so a flood can't force large allocations + JSON parses.
const INBOUND_BODY_LIMIT_BYTES: usize = 1024 * 1024;

pub fn email_inbound_router(state: EmailInboundState) -> Router {
    Router::new()
        .route(
            "/email-inbound/{app_id}/{trigger_id}",
            post(receive_inbound_email),
        )
        .layer(axum::extract::DefaultBodyLimit::max(
            INBOUND_BODY_LIMIT_BYTES,
        ))
        .with_state(state)
}

/// The generic provider-agnostic inbound shape. Users configure their
/// provider's webhook templating to POST this. `from` is required;
/// everything else defaults.
#[derive(Debug, Deserialize)]
struct InboundPayload {
    from: String,
    #[serde(default)]
    to: Vec<String>,
    #[serde(default)]
    cc: Vec<String>,
    #[serde(default)]
    subject: String,
    #[serde(default)]
    text: Option<String>,
    #[serde(default)]
    html: Option<String>,
    #[serde(default)]
    message_id: Option<String>,
}

async fn receive_inbound_email(
    State(s): State<EmailInboundState>,
    Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
    headers: HeaderMap,
    body: Bytes,
) -> Result<StatusCode, EmailInboundError> {
    // Resolve the trigger. 404 covers missing / wrong-kind / cross-app /
    // disabled — all "this URL doesn't address a live email trigger".
    let target = s
        .triggers
        .email_inbound_target(trigger_id)
        .await
        .map_err(|e| EmailInboundError::Backend(e.to_string()))?
        .ok_or(EmailInboundError::NotFound)?;
    if target.app_id != app_id || !target.enabled {
        return Err(EmailInboundError::NotFound);
    }

    // Audit 2026-06-11 H-B2 — bad-signature lockout. After BAD_SIG_BURST
    // failures within the window, refuse to even attempt verification
    // so HMAC + nonce-dedup + outbox-insert can't be hammered.
    if let Err(retry_after) = s.bad_sig_limiter.check(app_id, trigger_id) {
        return Err(EmailInboundError::TooManyBadSignatures { retry_after });
    }

    // Audit 2026-06-11 H-B2 — fail closed when no inbound_secret is
    // present. Creation now requires a secret (triggers_api), so this
    // path is unreachable for new triggers; remains as defense-in-depth
    // for any pre-audit row that still has a null secret column.
    let (Some(ct), Some(nonce)) = (
        target.inbound_secret_encrypted.as_ref(),
        target.inbound_secret_nonce.as_ref(),
    ) else {
        s.bad_sig_limiter.record_failure(app_id, trigger_id);
        return Err(EmailInboundError::Unauthorized);
    };
    // §M5.5 / audit H-D1: recover the SEALING owner for a v1 (AAD-bound) secret —
    // the source-template group for a materialized copy, else this app. v0 rows
    // ignore the owner (legacy no-AAD path).
    let sealing_owner = match target.sealing_group {
        Some(group_id) => SecretOwner::Group(group_id),
        None => SecretOwner::App(app_id),
    };
    let secret = decrypt_secret(
        &s.master_key,
        sealing_owner,
        ct,
        nonce,
        target.inbound_secret_version,
    )?;
    if let Err(err) = verify_signature(&headers, &body, secret.as_bytes(), &s.nonce_dedup) {
        s.bad_sig_limiter.record_failure(app_id, trigger_id);
        return Err(err);
    }

    // Parse the generic JSON shape. Malformed → 422.
    let payload: InboundPayload =
        serde_json::from_slice(&body).map_err(|e| EmailInboundError::Malformed(e.to_string()))?;

    let event = TriggerEvent::Email {
        from: payload.from,
        to: payload.to,
        cc: payload.cc,
        subject: payload.subject,
        text: payload.text,
        html: payload.html,
        received_at: chrono::Utc::now(),
        message_id: payload.message_id,
    };
    let payload_json = serde_json::to_value(&event)
        .map_err(|e| EmailInboundError::Backend(format!("serialize event: {e}")))?;

    s.outbox
        .insert(NewOutboxRow {
            app_id,
            source_kind: OutboxSourceKind::Email,
            trigger_id: Some(trigger_id),
            script_id: Some(target.script_id),
            reply_to: None,
            payload: payload_json,
            origin_principal: Some(target.registered_by_principal),
            // Inbound email is the root of a trigger chain (depth 1).
            trigger_depth: 1,
            root_execution_id: None,
        })
        .await
        .map_err(|e| EmailInboundError::Backend(e.to_string()))?;

    Ok(StatusCode::ACCEPTED)
}

/// Decrypt the stored inbound secret back to its raw string. Sealed as a JSON
/// string by the admin/apply layer; `open_email` dispatches on `version` (v0 =
/// legacy no-AAD, v1 = AAD bound to the sealing `owner`), yielding a
/// `Value::String`.
fn decrypt_secret(
    master_key: &MasterKey,
    owner: SecretOwner,
    ciphertext: &[u8],
    nonce: &[u8],
    version: i16,
) -> Result<String, EmailInboundError> {
    let value = open_email(master_key, owner, ciphertext, nonce, version).map_err(|_| {
        // Corrupted secret means we can't verify — fail closed (401).
        EmailInboundError::Unauthorized
    })?;
    value
        .as_str()
        .map(str::to_string)
        .ok_or(EmailInboundError::Unauthorized)
}

/// Constant-time HMAC-SHA256 verification of `timestamp || "." || body`
/// against the `X-Picloud-Signature` header (lowercase hex), with
/// replay protection.
///
/// F-S-010: binding `X-Picloud-Timestamp` into the signed input and
/// rejecting `|now - ts| > SIGNATURE_TIMESTAMP_TOLERANCE_SECS` bounds
/// the replay window; an in-process LRU keyed on
/// `(ts, sha256(body))` catches within-window replays. This is a
/// breaking change versus pre-v1.1.10 — webhook senders must include
/// the timestamp header and sign `ts || "." || body`.
fn verify_signature(
    headers: &HeaderMap,
    body: &[u8],
    secret: &[u8],
    nonce_dedup: &InboundNonceDedup,
) -> Result<(), EmailInboundError> {
    let provided_hex = headers
        .get(SIGNATURE_HEADER)
        .and_then(|h| h.to_str().ok())
        .ok_or(EmailInboundError::Unauthorized)?;
    let provided = hex::decode(provided_hex.trim()).map_err(|_| EmailInboundError::Unauthorized)?;
    let ts_raw = headers
        .get(TIMESTAMP_HEADER)
        .and_then(|h| h.to_str().ok())
        .ok_or(EmailInboundError::Unauthorized)?
        .trim();
    let ts: i64 = ts_raw
        .parse()
        .map_err(|_| EmailInboundError::Unauthorized)?;
    let now = chrono::Utc::now().timestamp();
    if (now - ts).abs() > SIGNATURE_TIMESTAMP_TOLERANCE_SECS {
        return Err(EmailInboundError::Unauthorized);
    }
    let mut mac =
        HmacSha256::new_from_slice(secret).map_err(|_| EmailInboundError::Unauthorized)?;
    mac.update(ts_raw.as_bytes());
    mac.update(b".");
    mac.update(body);
    mac.verify_slice(&provided)
        .map_err(|_| EmailInboundError::Unauthorized)?;

    let body_hash: [u8; 32] = Sha256::digest(body).into();
    nonce_dedup
        .record_or_reject(ts, body_hash)
        .map_err(|()| EmailInboundError::Unauthorized)
}

#[derive(Debug, thiserror::Error)]
pub enum EmailInboundError {
    #[error("trigger not found")]
    NotFound,
    #[error("invalid signature")]
    Unauthorized,
    /// Audit 2026-06-11 H-B2 — too many bad signatures for this trigger;
    /// locked out until the window rolls over.
    #[error("too many bad signatures; retry after {retry_after:?}")]
    TooManyBadSignatures { retry_after: Duration },
    #[error("malformed body: {0}")]
    Malformed(String),
    #[error("backend: {0}")]
    Backend(String),
}

impl IntoResponse for EmailInboundError {
    fn into_response(self) -> Response {
        let (status, body, retry_after) = match &self {
            Self::NotFound => (
                StatusCode::NOT_FOUND,
                json!({ "error": "trigger not found" }),
                None,
            ),
            Self::Unauthorized => (
                StatusCode::UNAUTHORIZED,
                json!({ "error": "invalid or missing signature" }),
                None,
            ),
            Self::TooManyBadSignatures { retry_after } => {
                let secs = retry_after.as_secs().max(1);
                (
                    StatusCode::TOO_MANY_REQUESTS,
                    json!({ "error": "too many bad signatures; trigger locked" }),
                    Some(secs),
                )
            }
            Self::Malformed(m) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": format!("malformed inbound email body: {m}") }),
                None,
            ),
            Self::Backend(e) => {
                tracing::error!(error = %e, "inbound email receiver backend error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                    None,
                )
            }
        };
        let mut resp = (status, Json(body)).into_response();
        if let Some(secs) = retry_after {
            if let Ok(v) = axum::http::HeaderValue::from_str(&secs.to_string()) {
                resp.headers_mut().insert("retry-after", v);
            }
        }
        resp
    }
}

#[cfg(test)]
mod tests {
    //! Unit tests for the security-critical helpers (HMAC verify, secret
    //! round-trip, payload parsing). The full request flow — 202 / 401 /
    //! 404 / 422 / cross-app — is exercised end-to-end against a real
    //! Postgres in `crates/picloud/tests/email_inbound.rs`.

    use super::*;
    use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES;
    use crate::secrets_service::{seal_email, seal_legacy};
    use picloud_shared::{AppId, GroupId};

    fn sign(secret: &[u8], ts: i64, body: &[u8]) -> String {
        let mut mac = HmacSha256::new_from_slice(secret).unwrap();
        mac.update(ts.to_string().as_bytes());
        mac.update(b".");
        mac.update(body);
        hex::encode(mac.finalize().into_bytes())
    }

    fn headers_with_sig(sig: &str, ts: i64) -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert(SIGNATURE_HEADER, sig.parse().unwrap());
        h.insert(TIMESTAMP_HEADER, ts.to_string().parse().unwrap());
        h
    }

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

    #[test]
    fn valid_signature_verifies() {
        let secret = b"shhh";
        let body = br#"{"from":"a@b.com"}"#;
        let ts = now_ts();
        let sig = sign(secret, ts, body);
        let dedup = InboundNonceDedup::new();
        assert!(verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).is_ok());
    }

    #[test]
    fn wrong_signature_rejected() {
        let body = br#"{"from":"a@b.com"}"#;
        let ts = now_ts();
        let sig = sign(b"shhh", ts, body);
        let dedup = InboundNonceDedup::new();
        let err =
            verify_signature(&headers_with_sig(&sig, ts), body, b"different", &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn missing_signature_header_rejected() {
        let mut h = HeaderMap::new();
        h.insert(TIMESTAMP_HEADER, now_ts().to_string().parse().unwrap());
        let dedup = InboundNonceDedup::new();
        let err = verify_signature(&h, b"body", b"secret", &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn missing_timestamp_header_rejected() {
        let mut h = HeaderMap::new();
        h.insert(SIGNATURE_HEADER, "deadbeef".parse().unwrap());
        let dedup = InboundNonceDedup::new();
        let err = verify_signature(&h, b"body", b"secret", &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn timestamp_outside_tolerance_rejected() {
        let secret = b"shhh";
        let body = br#"{"from":"a@b.com"}"#;
        let stale_ts = now_ts() - (SIGNATURE_TIMESTAMP_TOLERANCE_SECS + 60);
        let sig = sign(secret, stale_ts, body);
        let dedup = InboundNonceDedup::new();
        let err =
            verify_signature(&headers_with_sig(&sig, stale_ts), body, secret, &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn replay_within_window_rejected() {
        let secret = b"shhh";
        let body = br#"{"from":"a@b.com"}"#;
        let ts = now_ts();
        let sig = sign(secret, ts, body);
        let dedup = InboundNonceDedup::new();
        assert!(verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).is_ok());
        let err = verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn tampered_body_fails_verification() {
        let secret = b"shhh";
        let ts = now_ts();
        let sig = sign(secret, ts, b"original");
        let dedup = InboundNonceDedup::new();
        let err =
            verify_signature(&headers_with_sig(&sig, ts), b"tampered", secret, &dedup).unwrap_err();
        assert!(matches!(err, EmailInboundError::Unauthorized));
    }

    #[test]
    fn secret_round_trips_through_seal_open() {
        let key = MasterKey::from_bytes([3u8; 32]);
        // v0 (legacy no-AAD) still opens — backward compatibility for pre-M3 rows.
        let (ct0, nonce0) = seal_legacy(
            &key,
            &serde_json::Value::String("provider-secret".into()),
            DEFAULT_SECRET_MAX_VALUE_BYTES,
        )
        .unwrap();
        let owner = SecretOwner::App(AppId::from(uuid::Uuid::from_u128(1)));
        let recovered = decrypt_secret(&key, owner, &ct0, &nonce0, 0).unwrap();
        assert_eq!(recovered, "provider-secret");

        // v1 (AAD bound to the sealing owner) round-trips under the SAME owner.
        let (ct1, nonce1, ver) = seal_email(
            &key,
            owner,
            &serde_json::Value::String("provider-secret".into()),
            DEFAULT_SECRET_MAX_VALUE_BYTES,
        )
        .unwrap();
        assert_eq!(ver, 1);
        assert_eq!(
            decrypt_secret(&key, owner, &ct1, &nonce1, ver).unwrap(),
            "provider-secret"
        );
        // A DIFFERENT owner (cross-tenant relocation) fails the GCM tag → 401.
        let other = SecretOwner::Group(GroupId::from(uuid::Uuid::from_u128(2)));
        assert!(
            decrypt_secret(&key, other, &ct1, &nonce1, ver).is_err(),
            "a v1 secret must not open under a different sealing owner"
        );
        let body = br#"{"from":"x@y.com"}"#;
        let ts = now_ts();
        let sig = sign(recovered.as_bytes(), ts, body);
        let dedup = InboundNonceDedup::new();
        assert!(verify_signature(
            &headers_with_sig(&sig, ts),
            body,
            recovered.as_bytes(),
            &dedup
        )
        .is_ok());
    }

    #[test]
    fn payload_requires_from_but_defaults_rest() {
        let ok: Result<InboundPayload, _> = serde_json::from_slice(br#"{"from":"a@b.com"}"#);
        let p = ok.expect("from-only payload parses");
        assert_eq!(p.from, "a@b.com");
        assert!(p.to.is_empty() && p.cc.is_empty() && p.text.is_none());

        // Missing `from` → malformed.
        let bad: Result<InboundPayload, _> = serde_json::from_slice(br#"{"subject":"hi"}"#);
        assert!(bad.is_err());
    }

    #[test]
    fn bad_sig_limiter_locks_out_after_burst() {
        // Audit 2026-06-11 H-B2 closure.
        let limiter = BadSignatureLimiter::new();
        let app = AppId::new();
        let trig = TriggerId::new();
        // BAD_SIG_BURST consecutive failures still allow check() (the
        // BURST'th failure is the one that bumps the bucket to full);
        // the BURST+1th check should be Err.
        for _ in 0..BAD_SIG_BURST {
            assert!(limiter.check(app, trig).is_ok());
            limiter.record_failure(app, trig);
        }
        let retry = limiter.check(app, trig).expect_err("should be locked out");
        assert!(retry <= BAD_SIG_WINDOW);
    }

    #[test]
    fn bad_sig_limiter_keys_independently_per_trigger() {
        let limiter = BadSignatureLimiter::new();
        let app = AppId::new();
        let a = TriggerId::new();
        let b = TriggerId::new();
        for _ in 0..BAD_SIG_BURST {
            limiter.record_failure(app, a);
        }
        assert!(limiter.check(app, a).is_err(), "a locked");
        assert!(limiter.check(app, b).is_ok(), "b independent");
    }
}
