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

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

/// Semantic role of a script (v1.1.3).
///
/// `Endpoint` scripts have an executable entry point — they bind to HTTP
/// routes and act as trigger handlers. `Module` scripts are libraries of
/// `fn`/`const` declarations imported by other scripts via Rhai's
/// `import "<name>" as <alias>;` syntax. Modules cannot be invoked
/// directly: route binding and trigger creation reject `Module` targets.
///
/// Serialized as `"endpoint"` / `"module"` so the wire shape is the
/// same string the SQL `CHECK (kind IN ('endpoint','module'))`
/// constraint enforces.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScriptKind {
    #[default]
    Endpoint,
    Module,
}

impl ScriptKind {
    /// Wire / SQL representation. Inverse of `parse_str`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Endpoint => "endpoint",
            Self::Module => "module",
        }
    }

    /// Parse the canonical wire / SQL form. Returns `None` for any
    /// other input; callers map that to a 400 / `ValidationError`.
    /// Named `parse_str` (not `from_str`) to dodge the
    /// `std::str::FromStr` lint without taking on the trait's
    /// `Result<Self, Self::Err>` shape that this caller doesn't need.
    #[must_use]
    pub fn parse_str(s: &str) -> Option<Self> {
        match s {
            "endpoint" => Some(Self::Endpoint),
            "module" => Some(Self::Module),
            _ => None,
        }
    }
}

#[cfg(test)]
mod kind_tests {
    use super::*;

    #[test]
    fn default_is_endpoint() {
        assert_eq!(ScriptKind::default(), ScriptKind::Endpoint);
    }

    #[test]
    fn round_trips_through_serde_lowercase() {
        assert_eq!(
            serde_json::to_string(&ScriptKind::Endpoint).unwrap(),
            "\"endpoint\""
        );
        assert_eq!(
            serde_json::to_string(&ScriptKind::Module).unwrap(),
            "\"module\""
        );
        assert_eq!(
            serde_json::from_str::<ScriptKind>("\"endpoint\"").unwrap(),
            ScriptKind::Endpoint
        );
        assert_eq!(
            serde_json::from_str::<ScriptKind>("\"module\"").unwrap(),
            ScriptKind::Module
        );
    }

    #[test]
    fn parse_str_round_trip() {
        for k in [ScriptKind::Endpoint, ScriptKind::Module] {
            assert_eq!(ScriptKind::parse_str(k.as_str()), Some(k));
        }
        assert_eq!(ScriptKind::parse_str("invalid"), None);
        assert_eq!(ScriptKind::parse_str(""), None);
    }
}

/// A user-uploaded Rhai script and its execution configuration.
///
/// This is the canonical representation that flows between manager (storage),
/// orchestrator (dispatch), and executor (run). It must stay serializable
/// because in cluster mode it crosses process boundaries on every invocation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
    pub id: ScriptId,
    /// Owning app, when app-owned. Set on create, immutable thereafter — a
    /// "move to another app" is a copy+delete, not an in-place edit
    /// (snapshot semantics — see blueprint §11.5).
    ///
    /// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
    /// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
    /// A group-owned script (`app_id: None`, `group_id: Some`) is a template
    /// inherited by every descendant app — it has no single app, so the
    /// **execution context** app is supplied by the route/trigger/caller that
    /// invoked it, never read off the script. Reading `app_id` to mean "the
    /// app this runs under" is therefore a bug for group scripts; use the
    /// invoking surface's app_id instead.
    #[serde(default)]
    pub app_id: Option<AppId>,
    /// Owning group, when group-owned (Phase 4). Mutually exclusive with
    /// `app_id`. The script is resolved by name, nearest-owner-wins, down the
    /// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
    /// the same name shadows the inherited one).
    #[serde(default)]
    pub group_id: Option<GroupId>,
    pub name: String,
    pub description: Option<String>,
    pub version: i32,
    pub source: String,

    /// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported)
    /// or `Module` (v1.1.3 — imported by other scripts, never bound
    /// directly to a route or trigger).
    #[serde(default)]
    pub kind: ScriptKind,

    pub timeout_seconds: u32,

    /// Per-script overrides for Rhai sandbox limits. Empty = platform
    /// defaults. Values are admin-ceiling-clamped at write time.
    #[serde(default)]
    pub sandbox: ScriptSandbox,

    /// **v1.3+ advisory only.** Real heap limits require OS-level
    /// isolation (cgroups, process limits) which lands with cluster-mode
    /// executor sandboxing. The field stays in the schema so we don't
    /// have to add it back when that's built.
    pub memory_limit_mb: u32,

    /// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
    /// script is not invocable (route 404s, trigger doesn't fire) but stays
    /// as desired state (not pruned). Defaults `true`.
    #[serde(default = "crate::default_true")]
    pub enabled: bool,

    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
/// in-memory witness of that invariant so call sites can `match` exhaustively
/// instead of juggling two `Option`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ScriptOwner {
    App(AppId),
    Group(GroupId),
}

impl Script {
    /// The script's owner, reconstructed from the polymorphic columns.
    /// A row that violates the exactly-one invariant (both/neither set —
    /// impossible under the CHECK) is reported as whichever is present,
    /// preferring the app, so a corrupt row never panics on the hot path.
    #[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,
        }
    }

    /// True iff this script is owned by `app` directly (not via a group).
    /// The cheap, app-owned-only ownership check — group inheritance is
    /// resolved separately (chain-membership), never by this method.
    #[must_use]
    pub fn is_owned_by_app(&self, app: AppId) -> bool {
        self.app_id == Some(app)
    }
}
