//! `EmailService` — the v1.1.7 outbound email contract.
//!
//! Scripts get `email::send(#{...})` (plain text) and
//! `email::send_html(#{...})` (multipart text + HTML). Both route to the
//! single `send` trait method with an [`OutboundEmail`]; the bridge sets
//! `html` only for `send_html`.
//!
//! Lives in `picloud-shared` (not `manager-core`) so the Rhai bridge and
//! the impl share one trait. The impl (an SMTP relay over `lettre`)
//! lives in `manager-core::email_service`; `picloud-shared` stays free
//! of the `lettre` dependency.
//!
//! `app_id` is derived from `cx.app_id` (authz only — there is no
//! per-app `from` validation in v1.1.7; deliverability is the operator's
//! SMTP-relay concern).

use async_trait::async_trait;
use thiserror::Error;

use crate::SdkCallCx;

/// A single outbound message. `to`/`cc`/`bcc` are address lists (the
/// bridge accepts a String or an Array of Strings). At least one of
/// `text` / `html` must be present.
#[derive(Debug, Clone, Default)]
pub struct OutboundEmail {
    pub to: Vec<String>,
    pub cc: Vec<String>,
    pub bcc: Vec<String>,
    pub from: String,
    /// Defaults to `from` when absent.
    pub reply_to: Option<String>,
    pub subject: String,
    pub text: Option<String>,
    pub html: Option<String>,
}

#[async_trait]
pub trait EmailService: Send + Sync {
    /// Validate, build, and send the message. Returns `Ok(())` once the
    /// SMTP relay has accepted it for delivery (not on actual delivery —
    /// that's the relay's job).
    async fn send(&self, cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError>;
}

/// Failure modes surfaced to the Rhai bridge.
#[derive(Debug, Error)]
pub enum EmailError {
    /// Caller principal lacked `AppEmailSend`. Only raised when
    /// `cx.principal.is_some()` (script-as-gate semantics).
    #[error("forbidden")]
    Forbidden,

    /// A required field (`to`, `from`, `subject`, or one of `text`/`html`)
    /// was missing or empty.
    #[error("missing required email field: {0}")]
    MissingField(String),

    /// An address failed basic RFC 5322-ish validation.
    #[error("invalid email address: {0}")]
    InvalidAddress(String),

    /// The assembled message exceeded the per-message size cap.
    #[error("email too large: {actual} bytes exceeds the {limit}-byte limit")]
    TooLarge { limit: usize, actual: usize },

    /// No SMTP relay is configured (HOST/USER/PASSWORD unset). Every
    /// `send` fails until the operator configures one.
    #[error(
        "email is not configured: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable outbound email"
    )]
    NotConfigured,

    /// The SMTP relay rejected the message or the connection failed.
    #[error("email transport error: {0}")]
    Transport(String),

    /// Audit 2026-06-11 H-H1 — the message recipient count exceeded
    /// the per-message cap (default 20, override via
    /// `PICLOUD_EMAIL_MAX_RECIPIENTS`).
    #[error("too many recipients: {actual} exceeds the per-message cap of {limit}")]
    TooManyRecipients { limit: usize, actual: usize },

    /// Audit 2026-06-11 H-H1 — per-(app, recipient) or per-app burst
    /// limit hit. The string names which bucket so operators can
    /// distinguish "this user is being spammed" from "this app is
    /// blowing through its SMTP quota."
    #[error("email rate limit exceeded ({0})")]
    RateLimited(&'static str),
}

/// Stub used by test harnesses that build a `Services` bundle without an
/// SMTP relay. Every call returns `EmailError::NotConfigured`.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEmailService;

#[async_trait]
impl EmailService for NoopEmailService {
    async fn send(&self, _cx: &SdkCallCx, _email: OutboundEmail) -> Result<(), EmailError> {
        Err(EmailError::NotConfigured)
    }
}
