//! `ModuleSource` — the Rhai module-loading contract (v1.1.3; made
//! origin-aware in v1.2 Phase 4b).
//!
//! The executor-core `PicloudModuleResolver` calls into this trait to
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
//! statements. The Postgres impl in `manager-core` reads from the
//! `scripts` table; tests pin in-memory fakes.
//!
//! **Resolution is lexical (§5.5).** A module is resolved against the
//! module set visible at the **importing script's own defining node**
//! (`origin`), walking up the group chain from there — *not* the
//! inheriting app's effective view. So `resolve` is keyed by a
//! [`ScriptOwner`] origin, not by `cx.app_id`. This deliberately differs
//! from the SDK-call isolation boundary: a group script's `import`
//! resolves from the **group** (sealed from below — a leaf app cannot
//! shadow it), while every SDK call *inside* that module still scopes to
//! the inheriting app via `cx.app_id`. The `origin` argument is a trusted
//! dispatch-derived value (the resolved script's owner), never a
//! script-passed argument, so the cross-app isolation boundary holds.

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

use crate::{AppId, GroupId, ScriptId, ScriptOwner};

/// A module script as returned by `ModuleSource::resolve`. Carries the
/// fields the resolver needs: the id (cache key + diagnostics), the
/// resolved module's **owner** (so nested imports inside it resolve from
/// *its* defining node), the source (to compile), and `updated_at` (the
/// cache-staleness comparator).
///
/// Ownership is polymorphic (Phase 4): exactly one of `app_id` / `group_id`
/// is set — mirroring [`crate::Script`] and the DB CHECK.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleScript {
    pub script_id: ScriptId,
    /// Owning app, when app-owned. Mutually exclusive with `group_id`.
    #[serde(default)]
    pub app_id: Option<AppId>,
    /// Owning group, when group-owned (Phase 4b group modules).
    #[serde(default)]
    pub group_id: Option<GroupId>,
    pub name: String,
    pub source: String,
    pub updated_at: DateTime<Utc>,
}

impl ModuleScript {
    /// The resolved module's defining node, reconstructed from the
    /// polymorphic columns. Used by the resolver to seal *this* module's
    /// own nested imports to its node (lexical chaining). Prefers the app
    /// if a corrupt row sets both, so the hot path never panics.
    #[must_use]
    pub fn owner(&self) -> Option<ScriptOwner> {
        match (self.app_id, self.group_id) {
            (Some(a), _) => Some(ScriptOwner::App(a)),
            (None, Some(g)) => Some(ScriptOwner::Group(g)),
            (None, None) => None,
        }
    }
}

/// Outcome of [`ModuleSource::resolve_policy`] — the §5.5 lexical-vs-dynamic
/// decision the resolver acts on.
#[derive(Debug)]
pub enum ModuleResolution {
    /// Bind this module (lexical, or an extension point's resolved provider).
    Module(ModuleScript),
    /// The name is an extension point, but no provider exists for the
    /// inheriting app (no app override and no default body) — a hard error.
    NoProvider,
    /// No declaration of the name anywhere on the chain → module-not-found.
    NotFound,
}

/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
/// module set up the chain rooted at `origin` (the importing script's
/// defining node), nearest-owner-wins. The `origin` is trusted
/// (dispatch-derived); the `name` is the literal between the import
/// quotes. See the module docs for why this is lexical, not `cx`-scoped.
#[async_trait]
pub trait ModuleSource: Send + Sync {
    /// Resolve a `kind = 'module'` script named `name`, visible from the
    /// `origin` node walking up its group chain (nearest depth wins).
    /// Returns `None` when no module of that name exists anywhere on the
    /// chain (or a row exists but its `kind` is `'endpoint'` — endpoints
    /// are never importable). The resolver surfaces `None` as
    /// `ErrorModuleNotFound` to Rhai.
    async fn resolve(
        &self,
        origin: ScriptOwner,
        name: &str,
    ) -> Result<Option<ModuleScript>, ModuleSourceError>;

    /// §5.5 extension-point-aware resolution. Decides lexical vs dynamic by
    /// the **nearest declaration** of `name` walking up `origin`'s chain: a
    /// concrete module → lexical (`resolve(origin, …)`); an extension-point
    /// marker → dynamic, resolved against the **inheriting app**
    /// (`resolve(App(inheriting_app), …)`) so the app can override, falling
    /// back to the default body up-chain; the EP wins on a depth tie.
    ///
    /// The default impl is lexical-only (no extension points) — the Postgres
    /// source overrides it. Test fakes that don't exercise EPs inherit this.
    async fn resolve_policy(
        &self,
        origin: ScriptOwner,
        inheriting_app: AppId,
        name: &str,
    ) -> Result<ModuleResolution, ModuleSourceError> {
        let _ = inheriting_app;
        Ok(match self.resolve(origin, name).await? {
            Some(m) => ModuleResolution::Module(m),
            None => ModuleResolution::NotFound,
        })
    }
}

/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
/// not exceptional — it's `Ok(None)`.
#[derive(Debug, Error)]
pub enum ModuleSourceError {
    /// Backend (Postgres, network, etc.) unavailable or returned an
    /// error. The string is safe to surface to a script (Rhai wraps
    /// it in `ErrorModuleNotFound` with the module name + reason).
    #[error("module backend error: {0}")]
    Backend(String),
}

/// Stub used by the executor-core test harness so engine integration
/// tests don't need a real DB-backed source. Every resolve returns
/// `Ok(None)` — `import "x"` always errors as "module not found"
/// under this impl.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopModuleSource;

#[async_trait]
impl ModuleSource for NoopModuleSource {
    async fn resolve(
        &self,
        _origin: ScriptOwner,
        _name: &str,
    ) -> Result<Option<ModuleScript>, ModuleSourceError> {
        Ok(None)
    }
}
