//! `/api/v1/admin/apps/{id}/triggers/*` — trigger CRUD admin endpoints.
//!
//! Per design notes §2, two kinds ship in v1.1.1: `kv` (with
//! collection_glob + ops) and `dead_letter` (with optional source /
//! trigger_id / script_id filters). Separate endpoints per kind keep
//! validation clean.
//!
//! Every endpoint is guarded by `Capability::AppManageTriggers(app_id)`
//! evaluated after the resource lookup so the capability binds to the
//! resource's actual `app_id` (mirrors `apps_api`).

use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_shared::{
    AppId, DocsEventOp, FilesEventOp, KvEventOp, MasterKey, Principal, ScriptId, ScriptKind,
    TriggerId,
};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::secrets_service::{seal_email, SecretOwner};
use crate::trigger_config::{BackoffShape, TriggerConfig};
use crate::trigger_repo::{
    CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
    CreateFilesTrigger, CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode,
    TriggerRepo, TriggerRepoError,
};

/// Hard floor on queue visibility timeout. Anything shorter is a typo
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
/// every 30s; an executor needs more wall-clock than this to do useful
/// work). Reject below this with a 422 + actionable error.
pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;

/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
/// is unset. Re-exported from the dispatcher so the single source of
/// truth for the executor-budget default lives in one place — a future
/// change to the dispatcher default shifts the visibility-timeout warn
/// boundary in lockstep.
use crate::dispatcher::DEFAULT_ASYNC_EXEC_TIMEOUT_SECS as DEFAULT_SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS;

/// Read the dispatcher's per-message executor budget at validation
/// time so the warn-threshold tracks the live env-overridable value
/// (`PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`). Without this, a deploy
/// that raises the budget produces false-positive warns and a deploy
/// that lowers it misses real races.
fn safe_visibility_vs_exec_budget_secs() -> u32 {
    if let Ok(v) = std::env::var("PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC") {
        if let Ok(n) = v.trim().parse::<u32>() {
            if n > 0 {
                return n;
            }
        }
    }
    DEFAULT_SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS
}

/// Validate the script-supplied visibility timeout. Hard-rejects below
/// [`MIN_QUEUE_VISIBILITY_TIMEOUT_SECS`]; warn-logs (does not reject)
/// when below `safe_limit_secs` — a handler running past
/// `visibility_secs` will get double-delivered when reclaim sweeps the
/// stale claim.
///
/// `safe_limit_secs` is parameterized so the call site reads the
/// live env-overridable executor budget (PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC)
/// and the tests can inject explicit boundaries without env-var races.
fn validate_queue_visibility_timeout(
    secs: Option<u32>,
    safe_limit_secs: u32,
) -> Result<(), TriggersApiError> {
    let Some(s) = secs else {
        return Ok(()); // None → fall back to TriggerConfig's default.
    };
    if s < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS {
        return Err(TriggersApiError::Invalid(format!(
            "visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \
             (shorter than the dispatcher's tick + reclaim cadence; \
             a handler can't return before reclaim fires)"
        )));
    }
    if s < safe_limit_secs {
        tracing::warn!(
            visibility_timeout_secs = s,
            executor_budget_secs = safe_limit_secs,
            "queue trigger visibility_timeout_secs is below the dispatcher's per-message \
             executor budget; a handler that runs longer than {s}s will be reclaimed \
             mid-execution and the message double-delivered. Either raise visibility or \
             accept the at-least-once semantics for this queue."
        );
    }
    Ok(())
}

#[derive(Clone)]
pub struct TriggersState {
    pub triggers: Arc<dyn TriggerRepo>,
    pub apps: Arc<dyn AppRepository>,
    pub authz: Arc<dyn AuthzRepo>,
    /// v1.1.3: trigger creation must verify the target script (1) exists,
    /// (2) belongs to this app, and (3) is `kind = endpoint` — modules
    /// cannot be invoked. The script-load lives in the handler, so the
    /// state needs a repo handle.
    pub scripts: Arc<dyn ScriptRepository>,
    /// Defaults applied to created triggers when the request omits
    /// retry settings. Kept on the state struct so tests can swap
    /// in a stricter / looser config without env tinkering.
    pub config: TriggerConfig,
    /// v1.1.7: master key used to encrypt an email trigger's inbound HMAC
    /// secret before it's stored.
    pub master_key: MasterKey,
}

pub fn triggers_router(state: TriggersState) -> Router {
    Router::new()
        .route(
            "/apps/{app_id}/triggers",
            get(list_triggers).delete(noop_405),
        )
        .route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
        .route("/apps/{app_id}/triggers/docs", post(create_docs_trigger))
        .route("/apps/{app_id}/triggers/cron", post(create_cron_trigger))
        .route("/apps/{app_id}/triggers/files", post(create_files_trigger))
        .route(
            "/apps/{app_id}/triggers/pubsub",
            post(create_pubsub_trigger),
        )
        .route(
            "/apps/{app_id}/triggers/dead_letter",
            post(create_dl_trigger),
        )
        .route("/apps/{app_id}/triggers/email", post(create_email_trigger))
        .route("/apps/{app_id}/triggers/queue", post(create_queue_trigger))
        .route(
            "/apps/{app_id}/triggers/{trigger_id}",
            delete(delete_trigger),
        )
        .with_state(state)
}

async fn noop_405() -> StatusCode {
    StatusCode::METHOD_NOT_ALLOWED
}

// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct CreateKvTriggerRequest {
    pub script_id: ScriptId,
    pub collection_glob: String,
    /// Subset of `{insert, update, delete}`. Empty array means "any
    /// op" (the trigger fires on every mutation in matching
    /// collections).
    #[serde(default)]
    pub ops: Vec<KvEventOp>,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    /// Overrides for the platform retry defaults. Omitted fields fall
    /// back to `TriggerConfig` (env-overridable) at write time.
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

const fn default_dispatch() -> TriggerDispatchMode {
    TriggerDispatchMode::Async
}

/// v1.1.2. Same shape as `CreateKvTriggerRequest`; `ops` uses
/// `DocsEventOp` (`create` / `update` / `delete`) instead of
/// `KvEventOp` (`insert` / `update` / `delete`).
#[derive(Debug, Deserialize)]
pub struct CreateDocsTriggerRequest {
    pub script_id: ScriptId,
    pub collection_glob: String,
    #[serde(default)]
    pub ops: Vec<DocsEventOp>,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

/// v1.1.4 cron trigger. `schedule` is a 6-field cron expression (with
/// seconds); `timezone` is an IANA name (defaults to UTC if omitted).
#[derive(Debug, Deserialize)]
pub struct CreateCronTriggerRequest {
    pub script_id: ScriptId,
    pub schedule: String,
    #[serde(default = "default_timezone")]
    pub timezone: String,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

fn default_timezone() -> String {
    "UTC".to_string()
}

/// v1.1.5 files trigger. Mirrors `CreateKvTriggerRequest`; `ops` uses
/// `FilesEventOp` (`create` / `update` / `delete`).
#[derive(Debug, Deserialize)]
pub struct CreateFilesTriggerRequest {
    pub script_id: ScriptId,
    pub collection_glob: String,
    #[serde(default)]
    pub ops: Vec<FilesEventOp>,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

#[derive(Debug, Deserialize)]
pub struct CreateDeadLetterTriggerRequest {
    pub script_id: ScriptId,
    #[serde(default)]
    pub source_filter: Option<String>,
    #[serde(default)]
    pub trigger_id_filter: Option<TriggerId>,
    #[serde(default)]
    pub script_id_filter: Option<ScriptId>,
}

#[derive(Debug, Serialize)]
pub struct TriggerListResponse {
    pub triggers: Vec<Trigger>,
}

// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------

async fn list_triggers(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<TriggerListResponse>, TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;
    let triggers = s.triggers.list_for_app(app_id).await?;
    Ok(Json(TriggerListResponse { triggers }))
}

/// v1.1.3: shared check used by every trigger-create handler. Returns
/// `Ok(())` when the target script exists, lives in the same app, and
/// is `kind = endpoint`. Wrong app surfaces as 422 (not 404) so we
/// don't leak whether a script id exists in some other app.
async fn validate_trigger_target(
    scripts: &dyn ScriptRepository,
    app_id: AppId,
    script_id: ScriptId,
) -> Result<(), TriggersApiError> {
    let script = scripts
        .get(script_id)
        .await
        .map_err(map_script_repo_err)?
        .ok_or_else(|| {
            TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
        })?;
    // Phase 4: a trigger target must be invocable in this app's context —
    // owned by the app, or by a group on the app's ancestor chain (inherited).
    // App-owned is the in-memory fast path (no query); only a group-owned
    // candidate pays the chain lookup.
    let invocable = script.is_owned_by_app(app_id)
        || (script.group_id.is_some()
            && scripts
                .is_invocable_by_app(script_id, app_id)
                .await
                .map_err(map_script_repo_err)?);
    if !invocable {
        return Err(TriggersApiError::Invalid(format!(
            "script {script_id} does not belong to this app"
        )));
    }
    if script.kind == ScriptKind::Module {
        return Err(TriggersApiError::Invalid(format!(
            "script {script_id} has kind=module; modules cannot be trigger targets — \
             switch the script to kind=endpoint or attach this trigger to a different script"
        )));
    }
    Ok(())
}

fn map_script_repo_err(e: ScriptRepositoryError) -> TriggersApiError {
    // Surface as Invalid so the wire shape (422 with `error` field)
    // stays consistent with the other trigger-validation failures.
    // The underlying DB error is still logged through the manager's
    // tracing instrumentation.
    TriggersApiError::Invalid(format!("script lookup failed: {e}"))
}

async fn create_kv_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateKvTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    if input.collection_glob.trim().is_empty() {
        return Err(TriggersApiError::Invalid(
            "collection_glob must not be empty".into(),
        ));
    }
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = CreateKvTrigger {
        script_id: input.script_id,
        collection_glob: input.collection_glob,
        ops: input.ops,
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_kv_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn create_docs_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateDocsTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    if input.collection_glob.trim().is_empty() {
        return Err(TriggersApiError::Invalid(
            "collection_glob must not be empty".into(),
        ));
    }
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = CreateDocsTrigger {
        script_id: input.script_id,
        collection_glob: input.collection_glob,
        ops: input.ops,
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_docs_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn create_cron_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateCronTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    // Validate the schedule + timezone before touching the script repo
    // so a bad expression fails fast with a clear 422.
    crate::cron_scheduler::validate_schedule(&input.schedule)
        .map_err(|e| TriggersApiError::Invalid(format!("invalid cron schedule: {e}")))?;
    crate::cron_scheduler::validate_timezone(&input.timezone)
        .map_err(|e| TriggersApiError::Invalid(format!("invalid timezone: {e}")))?;

    // v1.1.3 check: target script exists, lives in this app, is an
    // endpoint (not a module).
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = CreateCronTrigger {
        script_id: input.script_id,
        schedule: input.schedule,
        timezone: input.timezone,
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_cron_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

/// v1.1.5 pubsub trigger. `topic_pattern` is validated to be exact /
/// `<prefix>.*` / `*`.
#[derive(Debug, Deserialize)]
pub struct CreatePubsubTriggerRequest {
    pub script_id: ScriptId,
    pub topic_pattern: String,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

/// v1.1.9. Inbound shape for a queue:receive trigger create.
/// `visibility_timeout_secs` defaults to TriggerConfig's per-queue
/// default if omitted; retry fields default to the standard trigger
/// retry policy.
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateQueueTriggerRequest {
    pub script_id: ScriptId,
    pub queue_name: String,
    #[serde(default)]
    pub visibility_timeout_secs: Option<u32>,
    #[serde(default = "default_dispatch")]
    pub dispatch_mode: TriggerDispatchMode,
    #[serde(default)]
    pub retry_max_attempts: Option<u32>,
    #[serde(default)]
    pub retry_backoff: Option<BackoffShape>,
    #[serde(default)]
    pub retry_base_ms: Option<u32>,
}

async fn create_pubsub_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreatePubsubTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    // Validate the topic pattern before touching the script repo so a
    // bad pattern fails fast with a clear 422.
    picloud_shared::validate_topic_pattern(&input.topic_pattern)
        .map_err(TriggersApiError::Invalid)?;
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = CreatePubsubTrigger {
        script_id: input.script_id,
        topic_pattern: input.topic_pattern,
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_pubsub_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn create_files_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateFilesTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    if input.collection_glob.trim().is_empty() {
        return Err(TriggersApiError::Invalid(
            "collection_glob must not be empty".into(),
        ));
    }
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = CreateFilesTrigger {
        script_id: input.script_id,
        collection_glob: input.collection_glob,
        ops: input.ops,
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_files_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn create_dl_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateDeadLetterTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
    let req = CreateDeadLetterTrigger {
        script_id: input.script_id,
        source_filter: input.source_filter,
        trigger_id_filter: input.trigger_id_filter,
        script_id_filter: input.script_id_filter,
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_dead_letter_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

#[derive(Debug, Deserialize)]
struct CreateEmailTriggerRequest {
    script_id: ScriptId,
    /// Shared HMAC secret the provider signs inbound POSTs with. `null`
    /// (or absent) means the trigger accepts unsigned POSTs.
    #[serde(default)]
    inbound_secret: Option<String>,
}

async fn create_email_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateEmailTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    // Audit 2026-06-11 H-B2 — inbound_secret is mandatory on create.
    // The previous behavior accepted None / empty and skipped HMAC
    // verification at runtime, which left the public webhook URL as the
    // only secret; URL discovery (logs, ops dashboards, brute force on
    // 122-bit TriggerId space) then fan-out-amplified into the outbox
    // for free.
    let secret = match input.inbound_secret {
        Some(s) if !s.trim().is_empty() => s,
        _ => {
            return Err(TriggersApiError::Invalid(
                "inbound_secret is required (audit 2026-06-11 H-B2): \
                 an HMAC-signed webhook URL is the only credential separating \
                 a real provider from an attacker"
                    .into(),
            ))
        }
    };
    // 64 KB cap is irrelevant for a signing secret, but `seal_email` takes one;
    // reuse the secrets default. §M5.5 / audit H-D1: sealed v1 with AAD bound to
    // the app owner (this is a standalone per-app trigger — never materialized).
    let (ct, nonce, version) = seal_email(
        &s.master_key,
        SecretOwner::App(app_id),
        &serde_json::Value::String(secret),
        crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
    )
    .map_err(|e| TriggersApiError::Invalid(format!("could not seal inbound_secret: {e}")))?;
    let inbound_secret_encrypted = Some(ct);
    let inbound_secret_nonce = Some(nonce.to_vec());

    let req = CreateEmailTrigger {
        script_id: input.script_id,
        inbound_secret_encrypted,
        inbound_secret_nonce,
        inbound_secret_version: version,
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_email_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn create_queue_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateQueueTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;

    if input.queue_name.trim().is_empty() {
        return Err(TriggersApiError::Invalid(
            "queue_name must not be empty".into(),
        ));
    }
    validate_queue_visibility_timeout(
        input.visibility_timeout_secs,
        safe_visibility_vs_exec_budget_secs(),
    )?;
    validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;

    let req = crate::trigger_repo::CreateQueueTrigger {
        script_id: input.script_id,
        queue_name: input.queue_name,
        visibility_timeout_secs: input
            .visibility_timeout_secs
            .unwrap_or(s.config.queue_default_visibility_timeout_secs),
        dispatch_mode: input.dispatch_mode,
        retry_max_attempts: input
            .retry_max_attempts
            .unwrap_or(s.config.retry_max_attempts),
        retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
        retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
        registered_by_principal: principal.user_id,
    };
    let created = s.triggers.create_queue_trigger(app_id, req).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn delete_trigger(
    State(s): State<TriggersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, trigger_id)): Path<(String, TriggerId)>,
) -> Result<StatusCode, TriggersApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    // Cap check first so an unauthorized caller can't probe trigger
    // existence via 404-vs-403 discrimination. Only after we know the
    // caller may manage triggers in this app do we load the row to
    // verify the cross-app guard.
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageTriggers(app_id),
    )
    .await?;
    let trigger = s
        .triggers
        .get(trigger_id)
        .await?
        .ok_or(TriggersApiError::NotFound(trigger_id))?;
    // Interactive trigger management is app-scoped; a group TEMPLATE
    // (app_id NULL) is never addressable here, so a mismatch (incl. None) 404s.
    if trigger.app_id != Some(app_id) {
        return Err(TriggersApiError::NotFound(trigger_id));
    }
    if !s.triggers.delete(trigger_id).await? {
        return Err(TriggersApiError::NotFound(trigger_id));
    }
    Ok(StatusCode::NO_CONTENT)
}

async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, TriggersApiError> {
    crate::app_repo::resolve_app(apps, ident)
        .await
        .map_err(|e| TriggersApiError::Backend(e.to_string()))?
        .map(|l| l.app.id)
        .ok_or_else(|| TriggersApiError::AppNotFound(ident.to_string()))
}

// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------

#[derive(Debug, thiserror::Error)]
pub enum TriggersApiError {
    #[error("app not found: {0}")]
    AppNotFound(String),

    #[error("trigger not found: {0}")]
    NotFound(TriggerId),

    #[error("invalid trigger: {0}")]
    Invalid(String),

    #[error("forbidden")]
    Forbidden,

    #[error("authorization repo error: {0}")]
    AuthzRepo(String),

    #[error("trigger backend: {0}")]
    Backend(String),
}

impl From<AuthzDenied> for TriggersApiError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
        }
    }
}

impl From<AuthzError> for TriggersApiError {
    fn from(e: AuthzError) -> Self {
        Self::AuthzRepo(e.to_string())
    }
}

impl From<TriggerRepoError> for TriggersApiError {
    fn from(e: TriggerRepoError) -> Self {
        match e {
            TriggerRepoError::NotFound(id) => Self::NotFound(id),
            TriggerRepoError::Invalid(s) => Self::Invalid(s),
            TriggerRepoError::Db(e) => Self::Backend(e.to_string()),
        }
    }
}

impl IntoResponse for TriggersApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::AppNotFound(_) | Self::NotFound(_) => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::Invalid(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "triggers authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Backend(e) => {
                tracing::error!(error = %e, "triggers api backend error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}

#[cfg(test)]
mod tests {
    //! In-memory tests for the trigger admin path. The Axum routing
    //! / extractor surface is exercised by integration tests (which
    //! need a real Postgres for the trigger repo); these tests cover
    //! the handlers' invariant logic — capability enforcement, app
    //! validation, default fallback for retry settings.

    use super::*;
    use crate::app_repo::{AppLookup, AppRepository};

    const TEST_SAFE_LIMIT: u32 = DEFAULT_SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS;

    #[test]
    fn visibility_timeout_none_passes() {
        assert!(validate_queue_visibility_timeout(None, TEST_SAFE_LIMIT).is_ok());
    }

    #[test]
    fn visibility_timeout_above_safe_passes() {
        // ≥ safe-limit: safely above the executor budget, no warn.
        assert!(validate_queue_visibility_timeout(Some(600), TEST_SAFE_LIMIT).is_ok());
        assert!(validate_queue_visibility_timeout(Some(TEST_SAFE_LIMIT), TEST_SAFE_LIMIT).is_ok());
    }

    #[test]
    fn visibility_timeout_between_min_and_safe_warns_but_passes() {
        // [MIN, safe): valid but the operator should know. We assert the
        // call succeeds; the warn-log emission is a side effect tracing
        // would surface in real ops — covered visually, not asserted.
        assert!(validate_queue_visibility_timeout(Some(60), TEST_SAFE_LIMIT).is_ok());
        assert!(validate_queue_visibility_timeout(
            Some(MIN_QUEUE_VISIBILITY_TIMEOUT_SECS),
            TEST_SAFE_LIMIT
        )
        .is_ok());
    }

    #[test]
    fn visibility_timeout_below_min_rejected() {
        for too_short in [0u32, 1, 5, 10, MIN_QUEUE_VISIBILITY_TIMEOUT_SECS - 1] {
            let err =
                validate_queue_visibility_timeout(Some(too_short), TEST_SAFE_LIMIT).unwrap_err();
            match err {
                TriggersApiError::Invalid(msg) => {
                    assert!(
                        msg.contains(&MIN_QUEUE_VISIBILITY_TIMEOUT_SECS.to_string()),
                        "error should cite the minimum: {msg}"
                    );
                }
                other => panic!("expected Invalid for {too_short}s, got {other:?}"),
            }
        }
    }

    /// The safe-limit threshold has to track the live env-overridable
    /// executor budget — a deploy that raises
    /// PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC must shift the warn
    /// boundary with it; a deploy that lowers it must keep us warning
    /// on the larger range. Tested via the explicit-parameter shape so
    /// we don't touch global env state from tests.
    #[test]
    fn safe_limit_threshold_tracks_caller_value() {
        // Operator raised the executor budget to 600s. A 400s
        // visibility — previously safe under the 300s default — is
        // now sub-budget and must warn (call succeeds, no err).
        assert!(validate_queue_visibility_timeout(Some(400), 600).is_ok());
        // Same value with the default 300s safe-limit: above the
        // threshold, also Ok but without the warn-log path.
        assert!(validate_queue_visibility_timeout(Some(400), 300).is_ok());
        // Operator lowered the budget to 60s. A 90s visibility is
        // above-safe, passes silently.
        assert!(validate_queue_visibility_timeout(Some(90), 60).is_ok());
    }

    use crate::trigger_repo::{
        CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger,
        DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch,
        KvTriggerMatch, Trigger, TriggerDetails, TriggerKind, TriggerRepo, TriggerRepoError,
    };
    use async_trait::async_trait;
    use chrono::Utc;
    use picloud_shared::{
        AdminUserId, App, AppRole, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
        UserId,
    };
    use std::collections::HashMap;
    use tokio::sync::Mutex;

    #[derive(Default)]
    struct InMemoryTriggerRepo {
        inner: Mutex<HashMap<TriggerId, Trigger>>,
    }

    #[async_trait]
    impl TriggerRepo for InMemoryTriggerRepo {
        async fn create_kv_trigger(
            &self,
            app_id: AppId,
            req: CreateKvTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::Kv,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Kv {
                    collection_glob: req.collection_glob,
                    ops: req.ops,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn create_docs_trigger(
            &self,
            app_id: AppId,
            req: CreateDocsTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::Docs,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Docs {
                    collection_glob: req.collection_glob,
                    ops: req.ops,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn create_dead_letter_trigger(
            &self,
            app_id: AppId,
            req: CreateDeadLetterTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::DeadLetter,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: 1,
                retry_backoff: BackoffShape::Constant,
                retry_base_ms: 0,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::DeadLetter {
                    source_filter: req.source_filter,
                    trigger_id_filter: req.trigger_id_filter,
                    script_id_filter: req.script_id_filter,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn create_email_trigger(
            &self,
            app_id: AppId,
            req: CreateEmailTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: TriggerKind::Email,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: 3,
                retry_backoff: BackoffShape::Exponential,
                retry_base_ms: 1000,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Email {
                    has_inbound_secret: req.inbound_secret_encrypted.is_some(),
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn email_inbound_target(
            &self,
            trigger_id: TriggerId,
        ) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
            let g = self.inner.lock().await;
            Ok(g.get(&trigger_id)
                // Mirror the production guard (`email_inbound_target` filters
                // `AND t.app_id IS NOT NULL`): a group-owned email TEMPLATE is
                // never a valid inbound target.
                .filter(|t| t.kind == TriggerKind::Email && t.app_id.is_some())
                .map(|t| EmailInboundTarget {
                    app_id: t.app_id.expect("filtered to app-owned above"),
                    script_id: t.script_id,
                    enabled: t.enabled,
                    dispatch_mode: t.dispatch_mode,
                    registered_by_principal: t.registered_by_principal,
                    inbound_secret_encrypted: None,
                    inbound_secret_nonce: None,
                    inbound_secret_version: 0,
                    sealing_group: None,
                }))
        }
        async fn create_cron_trigger(
            &self,
            app_id: AppId,
            req: CreateCronTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::Cron,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Cron {
                    schedule: req.schedule,
                    timezone: req.timezone,
                    last_fired_at: None,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn create_files_trigger(
            &self,
            app_id: AppId,
            req: CreateFilesTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::Files,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Files {
                    collection_glob: req.collection_glob,
                    ops: req.ops,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn create_pubsub_trigger(
            &self,
            app_id: AppId,
            req: CreatePubsubTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: crate::trigger_repo::TriggerKind::Pubsub,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Pubsub {
                    topic_pattern: req.topic_pattern,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
            Ok(self
                .inner
                .lock()
                .await
                .values()
                .filter(|t| t.app_id == Some(app_id))
                .cloned()
                .collect())
        }
        async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
            Ok(self.inner.lock().await.get(&id).cloned())
        }
        async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError> {
            Ok(self.inner.lock().await.remove(&id).is_some())
        }
        async fn list_matching_kv(
            &self,
            _app_id: AppId,
            _collection: &str,
            _op: KvEventOp,
        ) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
            Ok(vec![])
        }
        async fn list_matching_docs(
            &self,
            _app_id: AppId,
            _collection: &str,
            _op: DocsEventOp,
        ) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
            Ok(vec![])
        }
        async fn list_matching_files(
            &self,
            _app_id: AppId,
            _collection: &str,
            _op: FilesEventOp,
        ) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
            Ok(vec![])
        }
        async fn list_matching_dead_letter(
            &self,
            _app_id: AppId,
            _source: &str,
            _trigger_id: Option<TriggerId>,
            _script_id: Option<ScriptId>,
        ) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
            Ok(vec![])
        }
        async fn create_queue_trigger(
            &self,
            app_id: AppId,
            req: crate::trigger_repo::CreateQueueTrigger,
        ) -> Result<Trigger, TriggerRepoError> {
            let now = Utc::now();
            let id = TriggerId::new();
            let trigger = Trigger {
                id,
                app_id: Some(app_id),
                group_id: None,
                script_id: req.script_id,
                name: "mock".into(),
                kind: TriggerKind::Queue,
                enabled: true,
                sealed: false,
                shared: false,
                materialized: false,
                dispatch_mode: req.dispatch_mode,
                retry_max_attempts: req.retry_max_attempts,
                retry_backoff: req.retry_backoff,
                retry_base_ms: req.retry_base_ms,
                registered_by_principal: req.registered_by_principal,
                created_at: now,
                updated_at: now,
                details: TriggerDetails::Queue {
                    queue_name: req.queue_name,
                    visibility_timeout_secs: req.visibility_timeout_secs,
                    last_fired_at: None,
                },
            };
            self.inner.lock().await.insert(id, trigger.clone());
            Ok(trigger)
        }
        async fn list_active_queue_consumers(
            &self,
        ) -> Result<Vec<crate::trigger_repo::ActiveQueueConsumer>, TriggerRepoError> {
            Ok(vec![])
        }
        async fn touch_queue_trigger_last_fired_at(
            &self,
            _trigger_id: TriggerId,
            _at: chrono::DateTime<chrono::Utc>,
        ) -> Result<(), TriggerRepoError> {
            Ok(())
        }
    }

    struct InMemoryAppRepo {
        existing: Mutex<HashMap<AppId, App>>,
    }

    impl InMemoryAppRepo {
        fn with(app_id: AppId) -> Arc<Self> {
            let now = Utc::now();
            let mut existing = HashMap::new();
            existing.insert(
                app_id,
                App {
                    id: app_id,
                    slug: "test".into(),
                    name: "test".into(),
                    description: None,
                    group_id: picloud_shared::GroupId::new(),
                    created_at: now,
                    updated_at: now,
                },
            );
            Arc::new(Self {
                existing: Mutex::new(existing),
            })
        }
    }

    #[async_trait]
    impl AppRepository for InMemoryAppRepo {
        async fn create(
            &self,
            _slug: &str,
            _name: &str,
            _description: Option<&str>,
            _group_id: picloud_shared::GroupId,
        ) -> Result<App, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn create_with_takeover(
            &self,
            _slug: &str,
            _name: &str,
            _description: Option<&str>,
            _group_id: picloud_shared::GroupId,
        ) -> Result<App, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn slug_in_history(
            &self,
            _slug: &str,
        ) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn list(&self) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn list_for_user(
            &self,
            _user_id: AdminUserId,
        ) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn list_for_group(
            &self,
            _group_id: picloud_shared::GroupId,
        ) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn get_by_id(
            &self,
            id: AppId,
        ) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
            Ok(self.existing.lock().await.get(&id).cloned())
        }
        async fn get_by_slug(
            &self,
            slug: &str,
        ) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
            let map = self.existing.lock().await;
            Ok(map.values().find(|a| a.slug == slug).cloned())
        }
        async fn get_by_slug_or_history(
            &self,
            slug: &str,
        ) -> Result<Option<AppLookup>, crate::repo::ScriptRepositoryError> {
            let map = self.existing.lock().await;
            Ok(map
                .values()
                .find(|a| a.slug == slug)
                .cloned()
                .map(|app| AppLookup {
                    app,
                    redirected: false,
                }))
        }
        async fn update(
            &self,
            _id: AppId,
            _name: Option<&str>,
            _description: Option<Option<&str>>,
        ) -> Result<App, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn rename_slug(
            &self,
            _id: AppId,
            _new_slug: &str,
            _take_over_history: bool,
        ) -> Result<App, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn delete(&self, _id: AppId) -> Result<(), crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn delete_cascade(
            &self,
            _id: AppId,
        ) -> Result<(), crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn count_scripts_in_app(
            &self,
            _id: AppId,
        ) -> Result<i64, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
    }

    /// Minimal `ScriptRepository` impl backing the trigger-create
    /// handler's `validate_trigger_target` check. Tests insert one or
    /// more scripts via [`InMemoryScriptRepo::with_endpoint`] /
    /// [`with_module`] and pass it into `TriggersState`.
    struct InMemoryScriptRepo {
        existing: Mutex<HashMap<ScriptId, picloud_shared::Script>>,
    }

    impl InMemoryScriptRepo {
        fn empty() -> Arc<Self> {
            Arc::new(Self {
                existing: Mutex::new(HashMap::new()),
            })
        }
        fn with_endpoint(app_id: AppId, script_id: ScriptId) -> Arc<Self> {
            Self::with(app_id, script_id, ScriptKind::Endpoint)
        }
        fn with_module(app_id: AppId, script_id: ScriptId) -> Arc<Self> {
            Self::with(app_id, script_id, ScriptKind::Module)
        }
        fn with(app_id: AppId, script_id: ScriptId, kind: ScriptKind) -> Arc<Self> {
            let now = Utc::now();
            let mut existing = HashMap::new();
            existing.insert(
                script_id,
                picloud_shared::Script {
                    id: script_id,
                    app_id: Some(app_id),
                    group_id: None,
                    name: format!(
                        "{}_{}",
                        match kind {
                            ScriptKind::Endpoint => "endpoint",
                            ScriptKind::Module => "module",
                        },
                        script_id
                    ),
                    description: None,
                    version: 1,
                    source: String::new(),
                    kind,
                    timeout_seconds: 30,
                    sandbox: picloud_shared::ScriptSandbox::default(),
                    memory_limit_mb: 256,
                    enabled: true,
                    created_at: now,
                    updated_at: now,
                },
            );
            Arc::new(Self {
                existing: Mutex::new(existing),
            })
        }
    }

    #[async_trait]
    impl ScriptRepository for InMemoryScriptRepo {
        async fn get(
            &self,
            id: ScriptId,
        ) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            Ok(self.existing.lock().await.get(&id).cloned())
        }
        async fn get_by_name(
            &self,
            app_id: AppId,
            name: &str,
        ) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            Ok(self
                .existing
                .lock()
                .await
                .values()
                .find(|s| s.app_id == Some(app_id) && s.name == name)
                .cloned())
        }
        async fn get_by_name_inherited(
            &self,
            app_id: AppId,
            name: &str,
        ) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            // No hierarchy in this in-memory repo; inherited == own-scope.
            self.get_by_name(app_id, name).await
        }
        async fn is_invocable_by_app(
            &self,
            script_id: ScriptId,
            app_id: AppId,
        ) -> Result<bool, crate::repo::ScriptRepositoryError> {
            Ok(self
                .existing
                .lock()
                .await
                .get(&script_id)
                .is_some_and(|s| s.is_owned_by_app(app_id)))
        }
        async fn list(
            &self,
        ) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            Ok(self.existing.lock().await.values().cloned().collect())
        }
        async fn list_for_app(
            &self,
            _app_id: AppId,
        ) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn list_for_group(
            &self,
            _group_id: picloud_shared::GroupId,
        ) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn list_for_user(
            &self,
            _user_id: AdminUserId,
        ) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn create(
            &self,
            _input: crate::repo::NewScript,
        ) -> Result<picloud_shared::Script, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn update(
            &self,
            _id: ScriptId,
            _patch: crate::repo::ScriptPatch,
        ) -> Result<picloud_shared::Script, crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn delete(&self, _id: ScriptId) -> Result<(), crate::repo::ScriptRepositoryError> {
            unimplemented!()
        }
        async fn count_routes_for_script(
            &self,
            _script_id: ScriptId,
        ) -> Result<i64, crate::repo::ScriptRepositoryError> {
            Ok(0)
        }
        async fn count_triggers_for_script(
            &self,
            _script_id: ScriptId,
        ) -> Result<i64, crate::repo::ScriptRepositoryError> {
            Ok(0)
        }
        async fn list_imports(
            &self,
            _script_id: ScriptId,
        ) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
            Ok(vec![])
        }
    }

    struct AlwaysAllowAuthzRepo;
    #[async_trait]
    impl AuthzRepo for AlwaysAllowAuthzRepo {
        async fn membership(
            &self,
            _user_id: UserId,
            _app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(Some(AppRole::AppAdmin))
        }
    }

    struct AlwaysDenyAuthzRepo;
    #[async_trait]
    impl AuthzRepo for AlwaysDenyAuthzRepo {
        async fn membership(
            &self,
            _user_id: UserId,
            _app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(None)
        }
    }

    fn member_principal() -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: picloud_shared::InstanceRole::Member,
            scopes: None,
            app_binding: None,
        }
    }

    fn state_with(authz: Arc<dyn AuthzRepo>, app_id: AppId) -> TriggersState {
        TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz,
            scripts: InMemoryScriptRepo::empty(),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        }
    }

    /// Like [`state_with`] but pre-populates the script repo with a
    /// single endpoint script (so the v1.1.3 `validate_trigger_target`
    /// check passes and tests can exercise downstream behavior).
    fn state_with_endpoint(
        authz: Arc<dyn AuthzRepo>,
        app_id: AppId,
        script_id: ScriptId,
    ) -> TriggersState {
        TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz,
            scripts: InMemoryScriptRepo::with_endpoint(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        }
    }

    #[tokio::test]
    async fn unknown_app_returns_404() {
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), AppId::new());
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(AppId::new().to_string()), // a different (non-existent) app
            Json(CreateKvTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "*".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("missing app should error");
        assert!(matches!(err, TriggersApiError::AppNotFound(_)));
    }

    #[tokio::test]
    async fn member_without_role_is_forbidden() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "*".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("member without role should be forbidden");
        assert!(matches!(err, TriggersApiError::Forbidden));
    }

    #[tokio::test]
    async fn kv_trigger_uses_env_defaults_when_omitted() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let mut state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        // Tweak the config so we can detect that defaults were used.
        state.config.retry_max_attempts = 7;
        state.config.retry_base_ms = 12_345;
        let (status, Json(trigger)) = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id,
                collection_glob: "widgets".into(),
                ops: vec![KvEventOp::Insert],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await
        .unwrap();
        assert_eq!(status, StatusCode::CREATED);
        assert_eq!(trigger.retry_max_attempts, 7);
        assert_eq!(trigger.retry_base_ms, 12_345);
    }

    #[tokio::test]
    async fn empty_collection_glob_rejected() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "   ".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("empty glob should reject");
        assert!(matches!(err, TriggersApiError::Invalid(_)));
    }

    #[tokio::test]
    async fn docs_trigger_create_succeeds() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let (status, Json(trigger)) = create_docs_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateDocsTriggerRequest {
                script_id,
                collection_glob: "users".into(),
                ops: vec![DocsEventOp::Create, DocsEventOp::Update],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await
        .unwrap();
        assert_eq!(status, StatusCode::CREATED);
        assert!(matches!(
            trigger.kind,
            crate::trigger_repo::TriggerKind::Docs
        ));
        match trigger.details {
            TriggerDetails::Docs {
                collection_glob,
                ops,
            } => {
                assert_eq!(collection_glob, "users");
                assert_eq!(ops, vec![DocsEventOp::Create, DocsEventOp::Update]);
            }
            other => panic!("expected Docs details, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn docs_trigger_empty_glob_rejected() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let res = create_docs_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateDocsTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "   ".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("empty docs glob should reject");
        assert!(matches!(err, TriggersApiError::Invalid(_)));
    }

    #[tokio::test]
    async fn docs_trigger_member_without_role_is_forbidden() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let res = create_docs_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateDocsTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "users".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("member without role should be forbidden");
        assert!(matches!(err, TriggersApiError::Forbidden));
    }

    #[tokio::test]
    async fn delete_rejects_cross_app_trigger_id() {
        let app_a = AppId::new();
        let app_b = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_a);
        // Inject the app_b row into the in-memory apps repo too so
        // the path-existence check succeeds against app_a.
        // Insert a trigger that belongs to app_a.
        let trigger = state
            .triggers
            .create_kv_trigger(
                app_a,
                CreateKvTrigger {
                    script_id: ScriptId::new(),
                    collection_glob: "*".into(),
                    ops: vec![],
                    dispatch_mode: TriggerDispatchMode::Async,
                    retry_max_attempts: 3,
                    retry_backoff: BackoffShape::Exponential,
                    retry_base_ms: 1000,
                    registered_by_principal: AdminUserId::new(),
                },
            )
            .await
            .unwrap();
        let _ = app_b;

        // Attempt to delete via app_b's path — should 404.
        // First, give the in-memory app repo a record for app_b.
        // (Otherwise we'd 404 on app-existence before reaching the
        // cross-app check.)
        let state = TriggersState {
            apps: {
                let now = Utc::now();
                let mut existing = HashMap::new();
                existing.insert(
                    app_a,
                    App {
                        id: app_a,
                        slug: "a".into(),
                        name: "a".into(),
                        description: None,
                        group_id: picloud_shared::GroupId::new(),
                        created_at: now,
                        updated_at: now,
                    },
                );
                existing.insert(
                    app_b,
                    App {
                        id: app_b,
                        slug: "b".into(),
                        name: "b".into(),
                        description: None,
                        group_id: picloud_shared::GroupId::new(),
                        created_at: now,
                        updated_at: now,
                    },
                );
                Arc::new(InMemoryAppRepo {
                    existing: Mutex::new(existing),
                })
            },
            ..state
        };

        let res = delete_trigger(
            State(state),
            Extension(member_principal()),
            Path((app_b.to_string(), trigger.id)),
        )
        .await;
        let err = res.expect_err("cross-app delete should 404");
        assert!(matches!(err, TriggersApiError::NotFound(_)));
    }

    #[tokio::test]
    async fn delete_without_manage_cap_is_forbidden_not_notfound() {
        // Confirms the cap check fires before the trigger load — an
        // unauthorized caller can't probe trigger existence by trying
        // to delete one and reading 404 vs 403.
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let unknown_trigger = TriggerId::new();
        let res = delete_trigger(
            State(state),
            Extension(member_principal()),
            Path((app_id.to_string(), unknown_trigger)),
        )
        .await;
        let err = res.expect_err("denied cap should surface as Forbidden");
        assert!(
            matches!(err, TriggersApiError::Forbidden),
            "got {err:?}; expected Forbidden so unauthorized callers can't probe existence"
        );
    }

    // ----------------------------------------------------------------
    // v1.1.3: kind + cross-app target validation on trigger create.
    // ----------------------------------------------------------------

    #[tokio::test]
    async fn kv_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id,
                collection_glob: "widgets".into(),
                ops: vec![KvEventOp::Insert],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("module script should be rejected as trigger target");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(
            msg.to_lowercase().contains("module"),
            "expected error to mention 'module', got {msg}"
        );
    }

    #[tokio::test]
    async fn docs_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_docs_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateDocsTriggerRequest {
                script_id,
                collection_glob: "users".into(),
                ops: vec![DocsEventOp::Create],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("module script should be rejected as docs-trigger target");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("module"));
    }

    #[tokio::test]
    async fn dl_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_dl_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateDeadLetterTriggerRequest {
                script_id,
                source_filter: None,
                trigger_id_filter: None,
                script_id_filter: None,
            }),
        )
        .await;
        let err = res.expect_err("module script should be rejected as dead-letter target");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("module"));
    }

    #[tokio::test]
    async fn kv_trigger_rejects_missing_script() {
        let app_id = AppId::new();
        // Empty script repo — the requested script_id doesn't exist.
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id: ScriptId::new(),
                collection_glob: "widgets".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("missing script should reject");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("not found"));
    }

    #[tokio::test]
    async fn kv_trigger_rejects_cross_app_script() {
        // Latent v1.1.1/v1.1.2 isolation gap closed by v1.1.3: a
        // member of app A could previously target a script in app B.
        let app_a = AppId::new();
        let app_b = AppId::new();
        let script_id = ScriptId::new();
        // Pre-populate the script repo with the script living in app B,
        // but the trigger request targets app A.
        let scripts = InMemoryScriptRepo::with_endpoint(app_b, script_id);
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_a),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts,
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_a.to_string()),
            Json(CreateKvTriggerRequest {
                script_id,
                collection_glob: "widgets".into(),
                ops: vec![],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await;
        let err = res.expect_err("cross-app trigger target should reject");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(
            msg.to_lowercase().contains("does not belong"),
            "expected cross-app rejection message, got {msg}"
        );
    }

    // ----------------------------------------------------------------
    // v1.1.4: cron trigger create.
    // ----------------------------------------------------------------

    fn cron_req(script_id: ScriptId, schedule: &str, timezone: &str) -> CreateCronTriggerRequest {
        CreateCronTriggerRequest {
            script_id,
            schedule: schedule.into(),
            timezone: timezone.into(),
            dispatch_mode: TriggerDispatchMode::Async,
            retry_max_attempts: None,
            retry_backoff: None,
            retry_base_ms: None,
        }
    }

    #[tokio::test]
    async fn cron_trigger_create_succeeds() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let (status, Json(trigger)) = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(cron_req(
                script_id,
                "0 0 9 * * MON-FRI",
                "America/Los_Angeles",
            )),
        )
        .await
        .unwrap();
        assert_eq!(status, StatusCode::CREATED);
        assert!(matches!(
            trigger.kind,
            crate::trigger_repo::TriggerKind::Cron
        ));
        match trigger.details {
            TriggerDetails::Cron {
                schedule,
                timezone,
                last_fired_at,
            } => {
                assert_eq!(schedule, "0 0 9 * * MON-FRI");
                assert_eq!(timezone, "America/Los_Angeles");
                assert!(last_fired_at.is_none());
            }
            other => panic!("expected Cron details, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn cron_trigger_rejects_invalid_schedule() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let res = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            // 5-field expression — not the 6-field format we accept.
            Json(cron_req(script_id, "* * * * *", "UTC")),
        )
        .await;
        let err = res.expect_err("invalid schedule should reject");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("schedule"), "got {msg}");
    }

    #[tokio::test]
    async fn cron_trigger_rejects_unknown_timezone() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let res = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")),
        )
        .await;
        let err = res.expect_err("unknown timezone should reject");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("timezone"), "got {msg}");
    }

    #[tokio::test]
    async fn cron_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(cron_req(script_id, "0 * * * * *", "UTC")),
        )
        .await;
        let err = res.expect_err("module script should be rejected as cron target");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("module"), "got {msg}");
    }

    #[tokio::test]
    async fn cron_trigger_rejects_cross_app_script() {
        // v1.1.3 isolation gap regression: app A cannot target app B's
        // script via a cron trigger.
        let app_a = AppId::new();
        let app_b = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_a),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_a.to_string()),
            Json(cron_req(script_id, "0 * * * * *", "UTC")),
        )
        .await;
        let err = res.expect_err("cross-app cron target should reject");
        let msg = match err {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("does not belong"), "got {msg}");
    }

    #[tokio::test]
    async fn cron_trigger_member_without_role_is_forbidden() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let res = create_cron_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")),
        )
        .await;
        let err = res.expect_err("member without role should be forbidden");
        assert!(matches!(err, TriggersApiError::Forbidden));
    }

    #[tokio::test]
    async fn kv_trigger_accepts_endpoint_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let (status, _) = create_kv_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(CreateKvTriggerRequest {
                script_id,
                collection_glob: "widgets".into(),
                ops: vec![KvEventOp::Insert],
                dispatch_mode: TriggerDispatchMode::Async,
                retry_max_attempts: None,
                retry_backoff: None,
                retry_base_ms: None,
            }),
        )
        .await
        .expect("endpoint target should succeed");
        assert_eq!(status, StatusCode::CREATED);
    }

    // ----------------------------------------------------------------
    // v1.1.5: files + pubsub trigger create (Layout-E reject coverage).
    // ----------------------------------------------------------------

    fn files_req(script_id: ScriptId, glob: &str) -> CreateFilesTriggerRequest {
        CreateFilesTriggerRequest {
            script_id,
            collection_glob: glob.into(),
            ops: vec![FilesEventOp::Create],
            dispatch_mode: TriggerDispatchMode::Async,
            retry_max_attempts: None,
            retry_backoff: None,
            retry_base_ms: None,
        }
    }

    #[tokio::test]
    async fn files_trigger_create_succeeds() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let (status, Json(trigger)) = create_files_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(files_req(script_id, "avatars")),
        )
        .await
        .unwrap();
        assert_eq!(status, StatusCode::CREATED);
        assert!(matches!(
            trigger.kind,
            crate::trigger_repo::TriggerKind::Files
        ));
        match trigger.details {
            TriggerDetails::Files {
                collection_glob,
                ops,
            } => {
                assert_eq!(collection_glob, "avatars");
                assert_eq!(ops, vec![FilesEventOp::Create]);
            }
            other => panic!("expected Files details, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn files_trigger_empty_glob_rejected() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let res = create_files_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(files_req(ScriptId::new(), "   ")),
        )
        .await;
        assert!(matches!(
            res.expect_err("empty glob"),
            TriggersApiError::Invalid(_)
        ));
    }

    #[tokio::test]
    async fn files_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_files_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(files_req(script_id, "avatars")),
        )
        .await;
        let msg = match res.expect_err("module rejected") {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("module"));
    }

    #[tokio::test]
    async fn files_trigger_rejects_cross_app_script() {
        let app_a = AppId::new();
        let app_b = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_a),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_files_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_a.to_string()),
            Json(files_req(script_id, "avatars")),
        )
        .await;
        let msg = match res.expect_err("cross-app rejected") {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("does not belong"));
    }

    #[tokio::test]
    async fn files_trigger_member_without_role_is_forbidden() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let res = create_files_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(files_req(ScriptId::new(), "avatars")),
        )
        .await;
        assert!(matches!(
            res.expect_err("forbidden"),
            TriggersApiError::Forbidden
        ));
    }

    fn pubsub_req(script_id: ScriptId, pattern: &str) -> CreatePubsubTriggerRequest {
        CreatePubsubTriggerRequest {
            script_id,
            topic_pattern: pattern.into(),
            dispatch_mode: TriggerDispatchMode::Async,
            retry_max_attempts: None,
            retry_backoff: None,
            retry_base_ms: None,
        }
    }

    #[tokio::test]
    async fn pubsub_trigger_create_succeeds() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        let (status, Json(trigger)) = create_pubsub_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(pubsub_req(script_id, "user.*")),
        )
        .await
        .unwrap();
        assert_eq!(status, StatusCode::CREATED);
        match trigger.details {
            TriggerDetails::Pubsub { topic_pattern } => assert_eq!(topic_pattern, "user.*"),
            other => panic!("expected Pubsub details, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn pubsub_trigger_rejects_bad_pattern() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
        for bad in ["*.created", "a.*.b", "**"] {
            let res = create_pubsub_trigger(
                State(state.clone()),
                Extension(member_principal()),
                Path(app_id.to_string()),
                Json(pubsub_req(script_id, bad)),
            )
            .await;
            let msg = match res.expect_err("bad pattern") {
                TriggersApiError::Invalid(m) => m,
                other => panic!("expected Invalid, got {other:?}"),
            };
            assert!(
                msg.contains("unsupported pubsub topic pattern"),
                "got {msg} for {bad}"
            );
        }
    }

    #[tokio::test]
    async fn pubsub_trigger_rejects_module_target() {
        let app_id = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_id),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_module(app_id, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_pubsub_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(pubsub_req(script_id, "user.*")),
        )
        .await;
        let msg = match res.expect_err("module rejected") {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("module"));
    }

    #[tokio::test]
    async fn pubsub_trigger_rejects_cross_app_script() {
        let app_a = AppId::new();
        let app_b = AppId::new();
        let script_id = ScriptId::new();
        let state = TriggersState {
            triggers: Arc::new(InMemoryTriggerRepo::default()),
            apps: InMemoryAppRepo::with(app_a),
            authz: Arc::new(AlwaysAllowAuthzRepo),
            scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
            config: TriggerConfig::conservative(),
            master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
        };
        let res = create_pubsub_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_a.to_string()),
            Json(pubsub_req(script_id, "user.*")),
        )
        .await;
        let msg = match res.expect_err("cross-app rejected") {
            TriggersApiError::Invalid(m) => m,
            other => panic!("expected Invalid, got {other:?}"),
        };
        assert!(msg.to_lowercase().contains("does not belong"));
    }

    #[tokio::test]
    async fn pubsub_trigger_member_without_role_is_forbidden() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
        let res = create_pubsub_trigger(
            State(state),
            Extension(member_principal()),
            Path(app_id.to_string()),
            Json(pubsub_req(ScriptId::new(), "user.*")),
        )
        .await;
        assert!(matches!(
            res.expect_err("forbidden"),
            TriggersApiError::Forbidden
        ));
    }

    // ----------------------------------------------------------------
    // Slug-or-UUID path acceptance (post-aa493b9 refactor).
    // ----------------------------------------------------------------

    #[tokio::test]
    async fn list_accepts_slug_path() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let Json(resp) = list_triggers(
            State(state),
            Extension(member_principal()),
            Path("test".into()),
        )
        .await
        .expect("slug path should resolve");
        assert!(resp.triggers.is_empty());
    }

    #[tokio::test]
    async fn list_unknown_slug_returns_404() {
        let app_id = AppId::new();
        let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
        let err = list_triggers(
            State(state),
            Extension(member_principal()),
            Path("does-not-exist".into()),
        )
        .await
        .expect_err("unknown slug should 404");
        assert!(matches!(err, TriggersApiError::AppNotFound(_)));
    }
}
