//! `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 an extension-point lookup (§5.5). Present when `name` is
/// declared an extension point on `origin`'s chain *and* is not shadowed by
/// a nearer concrete module of the same name — i.e. the nearest declaration
/// of `name` is the extension point, so resolution inverts to the inheriting
/// app. `default_script_id` is the optional fallback module body to use when
/// the app provides no module of `name`.
#[derive(Debug, Clone)]
pub struct ExtPointResolution {
    pub default_script_id: Option<ScriptId>,
}

/// 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>;

    /// Extension-point check (§5.5). Returns `Some` iff the **nearest**
    /// declaration of `name` on `origin`'s chain is an extension point (not
    /// a concrete module). On a tie in depth a concrete module wins (so a
    /// nearer/peer real module shadows the extension-point declaration). The
    /// `origin` is the trusted importer node, so the inversion can only be
    /// opened by a declaration on the importer's own ancestry — a descendant
    /// can never make a parent's sealed import dynamic.
    async fn resolve_extension_point(
        &self,
        origin: ScriptOwner,
        name: &str,
    ) -> Result<Option<ExtPointResolution>, ModuleSourceError>;

    /// Load the extension-point fallback module `script_id`, but ONLY if it is
    /// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
    /// is the trusted importer node; constraining the lookup to its chain makes
    /// the resolver self-defending — a `default_script_id` that (through a bug
    /// or DB-direct write) pointed at another tenant's module resolves to
    /// `None` rather than executing cross-tenant code. Returns `None` if the id
    /// is absent, names an endpoint, or is off-chain.
    async fn resolve_by_id(
        &self,
        origin: ScriptOwner,
        script_id: ScriptId,
    ) -> Result<Option<ModuleScript>, ModuleSourceError>;
}

/// 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)
    }

    async fn resolve_extension_point(
        &self,
        _origin: ScriptOwner,
        _name: &str,
    ) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
        Ok(None)
    }

    async fn resolve_by_id(
        &self,
        _origin: ScriptOwner,
        _script_id: ScriptId,
    ) -> Result<Option<ModuleScript>, ModuleSourceError> {
        Ok(None)
    }
}
