//! `InvokeService` — the v1.1.9 function-composition contract.
//!
//! Backs the Rhai `invoke(target, args)` and `invoke_async(target, args)`
//! SDK calls. Resolves a target (by route path, by script name, or by
//! script id) within the **caller's app** to a `ResolvedScript`. Same-
//! app only; cross-app calls are rejected at the service entry point
//! (v1.1.x maintains strict isolation; cross-app sharing arrives in
//! v1.3+).
//!
//! The re-entrant Rhai pattern lives in `executor-core`: the bridge
//! captures `Arc<Engine>`, resolves the target via this trait, builds a
//! fresh `ExecRequest` with `trigger_depth + 1`, and calls
//! `Engine::execute(&source, req)` synchronously inside the caller's
//! `spawn_blocking` thread. Engine + AST cache are shared; only the
//! `SdkCallCx` changes per call.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;

use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx};

/// Identifies the script to invoke. Accepted by `invoke()` and
/// `invoke_async()` script-side.
///
/// - `Path("/users/:id")` → resolve via the orchestrator's route trie.
/// - `Name("payments_worker")` → `(cx.app_id, name) → script_id`.
/// - `Id(...)` → direct lookup, same-app verification afterward.
#[derive(Debug, Clone)]
pub enum InvokeTarget {
    Path(String),
    Name(String),
    Id(ScriptId),
}

impl InvokeTarget {
    /// Best-effort summary for error messages.
    #[must_use]
    pub fn describe(&self) -> String {
        match self {
            Self::Path(p) => format!("path {p:?}"),
            Self::Name(n) => format!("name {n:?}"),
            Self::Id(i) => format!("id {i}"),
        }
    }
}

/// The script the invoke service resolved. `app_id` MUST match the
/// caller's `cx.app_id` — the service enforces this and returns
/// `InvokeError::CrossApp` otherwise.
#[derive(Debug, Clone)]
pub struct ResolvedScript {
    pub script_id: ScriptId,
    pub app_id: AppId,
    /// The resolved script's **defining node** (Phase 4b) — the lexical
    /// origin its `import`s resolve against (§5.5). For an inherited group
    /// script this is the **group**, even though `app_id` (the execution
    /// boundary) is the caller's app. `None` only for a corrupt owner row;
    /// the executor falls back to `App(app_id)`.
    pub owner: Option<ScriptOwner>,
    pub source: String,
    pub updated_at: DateTime<Utc>,
    /// Script.name — surfaced to the callee as `ctx.script_name`.
    pub name: String,
}

#[async_trait]
pub trait InvokeService: Send + Sync {
    /// Resolve a target to its script + verify same-app. Used by both
    /// `invoke()` (sync) and `invoke_async()` (fire-and-forget).
    async fn resolve(
        &self,
        cx: &SdkCallCx,
        target: InvokeTarget,
    ) -> Result<ResolvedScript, InvokeError>;

    /// Fire-and-forget: write a v1.1.9 `OutboxSourceKind::Invoke` row
    /// the dispatcher consumes; return the new `ExecutionId`. No retry
    /// (one shot); the script wraps in `retry::with` if it wants more.
    async fn enqueue_async(
        &self,
        cx: &SdkCallCx,
        target: InvokeTarget,
        args: serde_json::Value,
    ) -> Result<ExecutionId, InvokeError>;
}

#[derive(Debug, Error)]
pub enum InvokeError {
    /// Target script not found in `cx.app_id`.
    #[error("invoke: target not found ({0})")]
    NotFound(String),

    /// Target exists but belongs to a different app. v1.1.x rejects
    /// cross-app invokes outright.
    #[error("invoke: target script belongs to a different app")]
    CrossApp,

    /// Depth limit exceeded — `cx.trigger_depth >= limits.trigger_depth_max`.
    /// Caller's `invoke()` would push the callee past the bound.
    #[error("invoke: depth limit exceeded (max {0})")]
    DepthExceeded(u32),

    /// Caller principal lacks the required capability. Anonymous public-
    /// HTTP scripts skip this check; authenticated callers gate.
    #[error("forbidden")]
    Forbidden,

    /// Payload could not be serialized (e.g. a Rhai FnPtr passed across
    /// the invoke boundary — closures are explicitly rejected).
    #[error("invoke rejected: {0}")]
    Rejected(String),

    /// Backend / database error. Named `Backend` to align with KvError /
    /// DocsError / FilesError / SecretsError / QueueError / PubsubError.
    #[error("invoke backend error: {0}")]
    Backend(String),
}

/// Test-only stub: every call errors. Lets harnesses build a `Services`
/// bundle without wiring an `InvokeService`.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopInvokeService;

#[async_trait]
impl InvokeService for NoopInvokeService {
    async fn resolve(
        &self,
        _cx: &SdkCallCx,
        _target: InvokeTarget,
    ) -> Result<ResolvedScript, InvokeError> {
        Err(InvokeError::Backend("invoke is not wired in".into()))
    }

    async fn enqueue_async(
        &self,
        _cx: &SdkCallCx,
        _target: InvokeTarget,
        _args: serde_json::Value,
    ) -> Result<ExecutionId, InvokeError> {
        Err(InvokeError::Backend("invoke is not wired in".into()))
    }
}
