//! Declarative project manifest (`picloud.toml`).
//!
//! One manifest describes the desired state of a **single app** — its
//! scripts, routes, triggers, and the *names* of the secrets it expects
//! (values are pushed out-of-band via `pic secret set`, never committed).
//!
//! This is the foundation of the declarative project tool (`pic pull` /
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
//! identical to the admin API — the CLI never depends on `manager-core`.
//!
//! All eight trigger kinds are representable except `dead_letter` (not
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
//! (a secret name) resolved server-side at apply.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use picloud_shared::{
    DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
    ScriptSandbox,
};
use serde::{Deserialize, Serialize};

/// Conventional manifest filename at a project root.
pub const MANIFEST_FILE: &str = "picloud.toml";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
    /// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
    /// Exactly one is present (enforced by [`Manifest::parse`]).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub app: Option<ManifestApp>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<ManifestGroup>,
    /// `[project]` (§7 multi-repo ownership) — the repo-root that OWNS the nodes
    /// it applies. Independent of the app/group node kind; declared in the
    /// repo's root manifest and threaded onto every apply from it. The first
    /// apply with a new slug claims each group node it touches.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<ManifestProject>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub scripts: Vec<ManifestScript>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub routes: Vec<ManifestRoute>,
    #[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
    pub triggers: ManifestTriggers,
    #[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
    pub secrets: ManifestSecrets,
    /// `[vars]` — app config key → value, reconciled at app scope `*`. Values
    /// are non-secret and live inline (unlike `[secrets]`, which is name-only).
    /// The overlay merges per-env, last-write-wins by key.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub vars: BTreeMap<String, toml::Value>,
    /// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
    /// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`].
    #[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")]
    pub suppress: ManifestSuppress,
    /// `[[workflows]]` (v1.2 Workflows) — declarative DAG orchestrations.
    /// App-owned; a `[group]` carrying them is rejected server-side.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub workflows: Vec<ManifestWorkflow>,
    /// `[[interceptors]]` (§9.4) — before-op allow/deny hooks. Each names a
    /// `script` and the `ops` it guards on a `service` (MVP: `service = "kv"`,
    /// `ops ⊆ [set, delete]`). Authored on an app OR group node.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub interceptors: Vec<ManifestInterceptor>,
}

/// One `[[interceptors]]` entry — an interceptor script and the operations it
/// guards. `ops` expands to one wire marker per `(service, op)` at apply.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestInterceptor {
    /// Name of the interceptor script (resolved on the node's chain at run time).
    pub script: String,
    /// The guarded service. MVP: only `"kv"`.
    #[serde(default = "default_interceptor_service")]
    pub service: String,
    /// The guarded operations, e.g. `["set", "delete"]`.
    pub ops: Vec<String>,
}

fn default_interceptor_service() -> String {
    "kv".to_string()
}

impl Manifest {
    /// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
    pub fn parse(text: &str) -> Result<Self> {
        let m: Self = toml::from_str(text).context("parsing manifest TOML")?;
        match (&m.app, &m.group) {
            (Some(_), None) | (None, Some(_)) => {}
            (Some(_), Some(_)) => {
                anyhow::bail!(
                    "manifest declares both [app] and [group]; a node is one or the other"
                )
            }
            (None, None) => {
                anyhow::bail!("manifest declares neither [app] nor [group]")
            }
        }
        // A group node owns scripts + vars (+ secret names) and, as of §11 tail,
        // ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
        // fan out live to descendant apps. As of §11 tail M1 a group may also
        // declare `[suppress]` to decline a template it inherits from a higher
        // ancestor, for its whole subtree.
        // §4.5 M5: a group may declare STATEFUL trigger templates too — cron,
        // queue, and email materialize a per-descendant-app row. An email
        // template resolves its `inbound_secret_ref` against the GROUP's own
        // secret store once at apply (shared-group-secret model); the server
        // rejects it there if the secret is unset.
        Ok(m)
    }

    /// This node's slug (app or group).
    #[must_use]
    pub fn slug(&self) -> &str {
        match (&self.app, &self.group) {
            (Some(a), _) => &a.slug,
            (_, Some(g)) => &g.slug,
            _ => "",
        }
    }

    /// True iff this manifest declares a `[group]` node (Phase 5).
    #[must_use]
    pub fn is_group(&self) -> bool {
        self.group.is_some()
    }

    /// This node's declared extension-point names (§5.5), from whichever of
    /// `[app]` / `[group]` is present.
    #[must_use]
    pub fn extension_points(&self) -> &[String] {
        match (&self.app, &self.group) {
            (Some(a), _) => &a.extension_points,
            (_, Some(g)) => &g.extension_points,
            _ => &[],
        }
    }

    /// This node's declared shared group collections (§11.6), normalized to
    /// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
    /// manifest carrying `collections` is a hard parse error (`ManifestApp` has
    /// no such field + `deny_unknown_fields`).
    #[must_use]
    pub fn collections(&self) -> Vec<(String, String)> {
        match &self.group {
            Some(g) => g
                .collections
                .iter()
                .map(CollectionDecl::normalized)
                .collect(),
            None => Vec::new(),
        }
    }

    /// Load and parse the manifest at `path`.
    pub fn load(path: &Path) -> Result<Self> {
        let body =
            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        Self::parse(&body)
    }

    /// Render to TOML text. Tables are emitted after scalars (the struct
    /// field order already satisfies TOML's "values before tables" rule).
    pub fn to_toml(&self) -> Result<String> {
        toml::to_string_pretty(self).context("serializing manifest TOML")
    }

    /// Load the base manifest, then (if `env` is set) merge the sparse
    /// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
    /// where "an environment is an app". The overlay carries per-env slug,
    /// secrets, and vars (overlay vars win per key); scripts/routes/triggers
    /// stay in the shared base.
    pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
        let mut base = Self::load(base_path)?;
        if let Some(env) = env {
            let path = overlay_path(base_path, env);
            let body = fs::read_to_string(&path).with_context(|| {
                format!(
                    "reading overlay {} for env `{env}` (expected next to the base manifest)",
                    path.display()
                )
            })?;
            let overlay: ManifestOverlay = toml::from_str(&body)
                .with_context(|| format!("parsing overlay {}", path.display()))?;
            base.apply_overlay(overlay);
        }
        Ok(base)
    }

    /// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
    /// replace the base's; overlay secret names union into the base set.
    fn apply_overlay(&mut self, overlay: ManifestOverlay) {
        if let Some(app) = &mut self.app {
            if let Some(slug) = overlay.app.slug {
                app.slug = slug;
            }
            if let Some(name) = overlay.app.name {
                app.name = name;
            }
        }
        for n in overlay.secrets.names {
            if !self.secrets.names.contains(&n) {
                self.secrets.names.push(n);
            }
        }
        // Overlay vars override base vars per key (the env-specific value of
        // an env-agnostic default); keys only in the base are kept.
        for (k, v) in overlay.vars {
            self.vars.insert(k, v);
        }
    }
}

/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
    let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
    let name = base_path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| MANIFEST_FILE.to_string());
    let stem = name.strip_suffix(".toml").unwrap_or(&name);
    parent.join(format!("{stem}.{env}.toml"))
}

/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
/// that vary per environment today — slug/name and secret names.
///
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
/// and `[vars]`. Scripts/routes/triggers belong in the shared base manifest,
/// so a `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in
/// an overlay is a mistake — error loudly rather than silently dropping it.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestOverlay {
    #[serde(default)]
    pub app: OverlayApp,
    #[serde(default)]
    pub secrets: ManifestSecrets,
    #[serde(default)]
    pub vars: BTreeMap<String, toml::Value>,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverlayApp {
    #[serde(default)]
    pub slug: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestApp {
    pub slug: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// `extension_points = ["theme", …]` (§5.5) — module names this node marks
    /// as provided/overridable by descendants. Name-only, like `[secrets]`; the
    /// optional default body is a co-located `[[scripts]]` module of the same
    /// name. A key of the `[app]` table so TOML can't mis-nest it.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extension_points: Vec<String>,
}

/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
/// and `[vars]`. The group must already exist on the server (created with
/// `pic groups create`); the manifest reconciles its content, not the tree
/// shape. In a nested project the parent is inferred from the directory tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestGroup {
    pub slug: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extension_points: Vec<String>,
    /// `collections = [...]` (§11.6) — shared collections this group offers as
    /// cross-app-shared (read by any app in the subtree, written by an
    /// authenticated editor+). Each entry is either a bare string (a `kv`
    /// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
    /// Group-only: there is no `collections` key on `[app]`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub collections: Vec<CollectionDecl>,
}

/// A `[project]` block (§7 multi-repo ownership): the identity of the repo-root
/// managing these nodes. The `slug` is committed (stable across clones); the
/// server assigns the UUID and the first apply registers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
    pub slug: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// `parent_group` (§7/§6) — the pre-existing group this repo binds UNDER
    /// (its attach point). Applies are refused for any node not strictly within
    /// that subtree. Absent = instance root = no ceiling (the default).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_group: Option<String>,
    /// §3 M3 per-env approval policy: `[project.environments]` maps an env name
    /// to its policy. A `confirm = true` env requires an explicit `--approve
    /// <env>` on `pic apply --env <env>` (a blanket `--yes` does NOT cover it —
    /// §4.2 "CI must opt in per environment"). The CLI gates client-side (fast
    /// fail) AND sends the policy to the server, which re-derives the gate from
    /// the request and enforces it (admin-gated on every declared node + audited).
    /// This closes the "patched/older CLI that still declares the project but
    /// skips the local check" bypass; it is NOT a hermetic boundary against a
    /// request that omits the policy entirely (the policy lives in the applier's
    /// manifest, not persisted server state — full closure would persist it).
    /// Sent only when non-empty (pre-M3 wire otherwise).
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub environments: std::collections::BTreeMap<String, EnvPolicy>,
}

/// §3 M3: the apply-gating policy for one environment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvPolicy {
    /// Require an explicit `--approve <env>` to apply to this env. NOT
    /// `#[serde(default)]`: an env listed with an empty policy (`prod = {}`)
    /// must fail to load (`missing field 'confirm'`) rather than silently
    /// parse as un-gated — listing an env to protect it, then having the gate
    /// quietly absent, is the failure mode to avoid.
    pub confirm: bool,
}

/// The store kind of a shared collection (§11.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CollectionKind {
    Kv,
    Docs,
    Files,
    /// §11.6 D2: a storeless group-scoped publish namespace, watched by
    /// `shared = true` group pubsub triggers.
    Topic,
    /// §11.6 D3: a group-keyed durable queue with competing per-descendant
    /// consumers.
    Queue,
}

impl CollectionKind {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Kv => "kv",
            Self::Docs => "docs",
            Self::Files => "files",
            Self::Topic => "topic",
            Self::Queue => "queue",
        }
    }
}

/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
/// alongside `{ name = "articles", kind = "docs" }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CollectionDecl {
    Name(String),
    Full {
        name: String,
        #[serde(default = "kv_kind")]
        kind: CollectionKind,
    },
}

fn kv_kind() -> CollectionKind {
    CollectionKind::Kv
}

impl CollectionDecl {
    /// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
    #[must_use]
    pub fn normalized(&self) -> (String, String) {
        match self {
            Self::Name(n) => (n.clone(), "kv".to_string()),
            Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestScript {
    pub name: String,
    /// Path to the `.rhai` source, relative to the manifest's directory.
    pub file: String,
    #[serde(default, skip_serializing_if = "is_endpoint")]
    pub kind: ScriptKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_seconds: Option<i32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory_limit_mb: Option<i32>,
    /// Per-script sandbox overrides; omitted entirely when no knob is set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox: Option<ScriptSandbox>,
    /// Three-state lifecycle (§4.3): `false` deploys the script inert (not
    /// invocable). Omitted ⇒ active; only serialized when disabled.
    #[serde(
        default = "picloud_shared::default_true",
        skip_serializing_if = "is_true"
    )]
    pub enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestRoute {
    /// Name of the script this route binds to.
    pub script: String,
    /// HTTP method; omit for ANY.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub method: Option<String>,
    pub host_kind: HostKind,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub host: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub host_param_name: Option<String>,
    pub path_kind: PathKind,
    pub path: String,
    #[serde(default, skip_serializing_if = "is_sync")]
    pub dispatch_mode: DispatchMode,
    /// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
    /// Omitted ⇒ active; only serialized when disabled.
    #[serde(
        default = "picloud_shared::default_true",
        skip_serializing_if = "is_true"
    )]
    pub enabled: bool,
    /// §11 tail: `sealed = true` on a `[group]` route template makes it
    /// non-suppressible — a descendant's `[suppress]` cannot decline it.
    /// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed.
    #[serde(default, skip_serializing_if = "is_false")]
    pub sealed: bool,
}

/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub kv: Vec<KvTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub docs: Vec<DocsTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<FilesTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cron: Vec<CronTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub pubsub: Vec<PubsubTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub email: Vec<EmailTriggerSpec>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub queue: Vec<QueueTriggerSpec>,
}

impl ManifestTriggers {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.kv.is_empty()
            && self.docs.is_empty()
            && self.files.is_empty()
            && self.cron.is_empty()
            && self.pubsub.is_empty()
            && self.email.is_empty()
            && self.queue.is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KvTriggerSpec {
    pub script: String,
    pub collection_glob: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ops: Vec<KvEventOp>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
    /// §11 tail: `sealed = true` on a `[group]` template makes it
    /// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed.
    #[serde(default, skip_serializing_if = "is_false")]
    pub sealed: bool,
    /// §11.6: `shared = true` on a `[group]` template makes it watch the group's
    /// SHARED collection (not per-app ones); group-only. Omitted ⇒ per-app.
    #[serde(default, skip_serializing_if = "is_false")]
    pub shared: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocsTriggerSpec {
    pub script: String,
    pub collection_glob: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ops: Vec<DocsEventOp>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
    /// See [`KvTriggerSpec::sealed`].
    #[serde(default, skip_serializing_if = "is_false")]
    pub sealed: bool,
    /// See [`KvTriggerSpec::shared`].
    #[serde(default, skip_serializing_if = "is_false")]
    pub shared: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FilesTriggerSpec {
    pub script: String,
    pub collection_glob: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ops: Vec<FilesEventOp>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
    /// See [`KvTriggerSpec::sealed`].
    #[serde(default, skip_serializing_if = "is_false")]
    pub sealed: bool,
    /// See [`KvTriggerSpec::shared`].
    #[serde(default, skip_serializing_if = "is_false")]
    pub shared: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CronTriggerSpec {
    pub script: String,
    /// 6-field cron expression (with seconds).
    pub schedule: String,
    #[serde(default = "default_timezone")]
    pub timezone: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PubsubTriggerSpec {
    pub script: String,
    pub topic_pattern: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
    /// See [`KvTriggerSpec::sealed`].
    #[serde(default, skip_serializing_if = "is_false")]
    pub sealed: bool,
    /// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a subtree
    /// app publishes to the group's declared shared topic, not on per-app
    /// publishes. Group-only; requires a declared `kind = "topic"` collection.
    #[serde(default, skip_serializing_if = "is_false")]
    pub shared: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmailTriggerSpec {
    pub script: String,
    /// Name of the secret (set via `pic secret set`) holding the inbound
    /// HMAC value — resolved + sealed server-side at apply. Never the value.
    pub inbound_secret_ref: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QueueTriggerSpec {
    pub script: String,
    pub queue_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub visibility_timeout_secs: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispatch_mode: Option<DispatchMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_max_attempts: Option<u32>,
    /// §11.6 D3: `true` for a shared-QUEUE group consumer over a declared
    /// `kind = "queue"` collection — competing per-descendant consumers drain
    /// one group-owned store. Group-only.
    #[serde(default, skip_serializing_if = "is_false")]
    pub shared: bool,
}

/// `[secrets] names = [...]` — declares which secrets the app expects.
/// Values are never in the manifest; `pic secret set` pushes them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestSecrets {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub names: Vec<String>,
}

impl ManifestSecrets {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.names.is_empty()
    }
}

/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
/// `triggers = [...]` names handler SCRIPTS whose inherited triggers this app
/// declines; `routes = [...]` names PATHS whose inherited route this app
/// declines (404s instead of serving). App-only — a `[group]` carrying
/// `[suppress]` is a hard parse error.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestSuppress {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub triggers: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub routes: Vec<String>,
}

impl ManifestSuppress {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.triggers.is_empty() && self.routes.is_empty()
    }
}

/// `[[workflows]]` (v1.2 Workflows) — a named DAG. Steps are authored as a
/// `[[workflows.steps]]` array of tables. The server nests `steps` under the
/// stored `definition` JSONB and validates the graph (acyclic, deps exist).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestWorkflow {
    pub name: String,
    /// Three-state lifecycle (§4.3); omitted ⇒ active.
    #[serde(
        default = "picloud_shared::default_true",
        skip_serializing_if = "is_true"
    )]
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub steps: Vec<ManifestWorkflowStep>,
}

/// One step of a `[[workflows]]` DAG. Serializes to the server's
/// `WorkflowStepDef` shape (the CLI nests these under `definition.steps`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestWorkflowStep {
    pub name: String,
    /// A function step invokes a script by name; exactly one of
    /// `function`/`workflow` is set (validated server-side).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,
    /// A workflow step starts a nested sub-workflow by name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workflow: Option<String>,
    /// Input mapping — an arbitrary TOML table whose string leaves may carry
    /// `{{ … }}` references, round-tripped to JSON for the wire.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input: Option<toml::Value>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub depends_on: Vec<String>,
    /// A `when` JSON-predicate condition (false ⇒ the step is skipped).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub when: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry: Option<ManifestWorkflowRetry>,
    /// `fail` (default) or `continue`. Typed against the shared enum so a typo
    /// (`on_error = "retry"`) fails at TOML parse with an "unknown variant"
    /// message pointing at this step, rather than an opaque server-side error.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_error: Option<picloud_shared::OnError>,
}

/// Per-step retry policy in the manifest (mirrors the server `WorkflowRetry`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestWorkflowRetry {
    pub max_attempts: u32,
    /// `constant` | `linear` | `exponential` (default). Typed against the shared
    /// enum so a bad value fails at TOML parse, not opaquely on the server.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub backoff: Option<picloud_shared::WorkflowBackoff>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_ms: Option<u32>,
}

// ---- serde skip/default helpers ----

fn is_endpoint(kind: &ScriptKind) -> bool {
    *kind == ScriptKind::Endpoint
}

fn is_sync(mode: &DispatchMode) -> bool {
    *mode == DispatchMode::Sync
}

/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
fn is_true(b: &bool) -> bool {
    *b
}

/// Skip-serialize helper: `sealed` defaults false, so only emit it when true.
fn is_false(b: &bool) -> bool {
    !*b
}

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

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

    fn sample() -> Manifest {
        Manifest {
            app: Some(ManifestApp {
                slug: "blog".into(),
                name: "My Blog".into(),
                description: Some("demo".into()),
                extension_points: vec!["theme".into()],
            }),
            group: None,
            project: None,
            scripts: vec![
                ManifestScript {
                    name: "create-post".into(),
                    file: "scripts/create-post.rhai".into(),
                    kind: ScriptKind::Endpoint,
                    description: None,
                    timeout_seconds: Some(10),
                    memory_limit_mb: Some(256),
                    sandbox: None,
                    enabled: true,
                },
                ManifestScript {
                    name: "lib".into(),
                    file: "scripts/lib.rhai".into(),
                    kind: ScriptKind::Module,
                    description: None,
                    timeout_seconds: None,
                    memory_limit_mb: None,
                    sandbox: Some(ScriptSandbox {
                        max_operations: Some(5_000_000),
                        ..ScriptSandbox::empty()
                    }),
                    enabled: false,
                },
            ],
            routes: vec![ManifestRoute {
                script: "create-post".into(),
                method: Some("POST".into()),
                host_kind: HostKind::Any,
                host: String::new(),
                host_param_name: None,
                path_kind: PathKind::Exact,
                path: "/posts".into(),
                dispatch_mode: DispatchMode::Sync,
                enabled: true,
                sealed: false,
            }],
            triggers: ManifestTriggers {
                cron: vec![CronTriggerSpec {
                    script: "create-post".into(),
                    schedule: "0 6 * * * *".into(),
                    timezone: "UTC".into(),
                    dispatch_mode: None,
                    retry_max_attempts: None,
                }],
                kv: vec![KvTriggerSpec {
                    script: "create-post".into(),
                    collection_glob: "users".into(),
                    ops: vec![KvEventOp::Insert, KvEventOp::Update],
                    dispatch_mode: Some(DispatchMode::Async),
                    retry_max_attempts: Some(5),
                    sealed: false,
                    shared: false,
                }],
                ..ManifestTriggers::default()
            },
            secrets: ManifestSecrets {
                names: vec!["STRIPE_KEY".into()],
            },
            vars: BTreeMap::from([
                ("region".to_string(), toml::Value::String("eu".into())),
                ("max-retries".to_string(), toml::Value::Integer(3)),
            ]),
            suppress: ManifestSuppress::default(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        }
    }

    #[test]
    fn round_trips_through_toml() {
        let m = sample();
        let text = m.to_toml().expect("serialize");
        let back = Manifest::parse(&text).expect("parse");
        assert_eq!(m, back, "manifest must survive a TOML round-trip");
    }

    #[test]
    fn omits_defaulted_fields() {
        let text = sample().to_toml().unwrap();
        // Endpoint kind + sync dispatch are defaults → not emitted.
        assert!(
            !text.contains("kind = \"endpoint\""),
            "default kind should be omitted:\n{text}"
        );
        assert!(
            !text.contains("dispatch_mode = \"sync\""),
            "default route dispatch should be omitted:\n{text}"
        );
        // Module kind IS non-default → emitted.
        assert!(text.contains("kind = \"module\""), "got:\n{text}");
        // `enabled` defaults true → omitted for the active script/route, but
        // the disabled `lib` script emits `enabled = false`.
        assert!(
            text.contains("enabled = false"),
            "a disabled entity must emit enabled:\n{text}"
        );
        assert!(
            !text.contains("enabled = true"),
            "active entities must omit the default:\n{text}"
        );
    }

    #[test]
    fn overlay_merges_slug_and_unions_secrets() {
        let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
        let overlay: ManifestOverlay = toml::from_str(
            "[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
        )
        .unwrap();
        m.apply_overlay(overlay);
        let app = m.app.as_ref().unwrap();
        assert_eq!(app.slug, "blog-staging", "overlay slug wins");
        assert_eq!(app.name, "My Blog", "base name kept when overlay omits it");
        assert_eq!(
            m.secrets.names,
            vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
            "secrets union, no dupes"
        );
        // Scripts/routes come from the base, untouched by the overlay.
        assert_eq!(m.scripts.len(), 2);
    }

    #[test]
    fn overlay_rejects_non_overlay_tables() {
        // An overlay carries only [app]/[secrets]. Scripts/routes/triggers
        // belong in the shared base, so a `[[scripts]]` table (or a typo'd
        // key) in an overlay must error loudly, not be silently dropped.
        let err = toml::from_str::<ManifestOverlay>(
            "[app]\nslug = \"blog-staging\"\n\n\
             [[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
        )
        .expect_err("overlay with [[scripts]] must be rejected");
        assert!(
            err.to_string().contains("scripts") || err.to_string().contains("unknown"),
            "error should point at the offending table: {err}"
        );

        // Typo'd key inside [app] is likewise rejected.
        toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
            .expect_err("overlay with a typo'd [app] key must be rejected");
    }

    #[test]
    fn overlay_path_derivation() {
        assert_eq!(
            overlay_path(Path::new("proj/picloud.toml"), "staging"),
            Path::new("proj/picloud.staging.toml")
        );
        assert_eq!(
            overlay_path(Path::new("custom.toml"), "prod"),
            Path::new("custom.prod.toml")
        );
    }

    #[test]
    fn empty_optional_sections_omitted() {
        let m = Manifest {
            app: Some(ManifestApp {
                slug: "x".into(),
                name: "X".into(),
                description: None,
                extension_points: vec![],
            }),
            group: None,
            project: None,
            scripts: vec![],
            routes: vec![],
            triggers: ManifestTriggers::default(),
            secrets: ManifestSecrets::default(),
            vars: BTreeMap::new(),
            suppress: ManifestSuppress::default(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let text = m.to_toml().unwrap();
        assert!(!text.contains("[[scripts]]"), "got:\n{text}");
        assert!(!text.contains("triggers"), "got:\n{text}");
        assert!(!text.contains("secrets"), "got:\n{text}");
        assert!(!text.contains("vars"), "got:\n{text}");
        assert!(!text.contains("extension_points"), "got:\n{text}");
        // Still round-trips.
        assert_eq!(m, Manifest::parse(&text).unwrap());
    }

    #[test]
    fn rejects_misplaced_or_unknown_keys() {
        // §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node
        // key. Placed at top level (before any table header) TOML reads it as a
        // root key — `deny_unknown_fields` must reject it loudly rather than
        // silently drop it (the silent-drop bug that bit in C5).
        let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n";
        assert!(
            Manifest::parse(misplaced).is_err(),
            "a top-level extension_points must be rejected, not silently ignored"
        );
        // A typo'd key inside [app] is likewise a hard error, not a drop.
        let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n";
        assert!(
            Manifest::parse(typo).is_err(),
            "an unknown key in [app] must be rejected"
        );
        // The correct node-key placement still parses.
        let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n";
        let m = Manifest::parse(ok).expect("node-key extension_points must parse");
        assert_eq!(m.extension_points(), ["theme"]);
    }

    #[test]
    fn collections_string_or_table_normalizes_kind() {
        // §11.6: a bare string is a `kv` collection (the shipped form); a
        // `{ name, kind }` table sets an explicit kind. Both coexist.
        let m = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \
             { name = \"assets\", kind = \"files\" }]\n",
        )
        .expect("string-or-table collections must parse");
        assert_eq!(
            m.collections(),
            vec![
                ("catalog".to_string(), "kv".to_string()),
                ("articles".to_string(), "docs".to_string()),
                ("assets".to_string(), "files".to_string()),
            ]
        );
        // An app manifest carrying `collections` is rejected (no such field +
        // deny_unknown_fields).
        let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
        assert!(
            Manifest::parse(app).is_err(),
            "collections on [app] must be rejected"
        );
    }

    #[test]
    fn app_and_group_suppress_parse() {
        // §11 tail: an [app] declares [suppress] to opt out of inherited
        // templates — script names (triggers) + paths (routes).
        let m = Manifest::parse(
            "[app]\nslug = \"blog\"\nname = \"Blog\"\n\n\
             [suppress]\ntriggers = [\"audit\"]\nroutes = [\"/hello\"]\n",
        )
        .expect("app [suppress] must parse");
        assert_eq!(m.suppress.triggers, ["audit"]);
        assert_eq!(m.suppress.routes, ["/hello"]);

        // §11 tail M1: a [group] MAY suppress — it declines a template it
        // inherits from a higher ancestor, for its whole subtree.
        let g = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [suppress]\ntriggers = [\"audit\"]\n",
        )
        .expect("group [suppress] must parse");
        assert_eq!(g.suppress.triggers, ["audit"]);

        // An unknown key inside [suppress] is a hard error (deny_unknown_fields).
        let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n";
        assert!(
            Manifest::parse(typo).is_err(),
            "an unknown key in [suppress] must be rejected"
        );
    }

    #[test]
    fn sealed_parses_on_group_route_and_trigger_templates() {
        // §11 tail: a [group] can mark a route/trigger template `sealed = true`
        // (non-suppressible). The default is unsealed.
        let m = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\
             host_kind = \"any\"\nsealed = true\n\n\
             [[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\
             [[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n",
        )
        .expect("sealed templates parse");
        assert!(m.routes[0].sealed, "route sealed must parse");
        assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse");
        assert!(
            !m.triggers.docs[0].sealed,
            "an omitted sealed defaults to false"
        );
    }

    #[test]
    fn group_manifest_parses_and_rejects_app_only_blocks() {
        // A [group] node: scripts + vars, no [app].
        let m = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
             [vars]\nregion = \"eu\"\n",
        )
        .expect("group manifest parses");
        assert!(m.is_group());
        assert_eq!(m.slug(), "acme");
        assert_eq!(m.scripts.len(), 1);

        // §11 tail: a group MAY carry ROUTE templates (inherited by descendants).
        let routed = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
        )
        .expect("group with a route template parses");
        assert!(routed.is_group());
        assert_eq!(routed.routes.len(), 1);

        // §4.5 M5: a group cron template is now ALLOWED (materialized per app).
        let cron = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
        )
        .expect("group cron template parses");
        assert_eq!(cron.triggers.cron.len(), 1);
        // §4.5 M5.5: a group email template is now ALLOWED too — it materializes
        // per descendant app and resolves its `inbound_secret_ref` against the
        // GROUP's own secret store at apply (the server rejects it there if the
        // secret is unset). Parse no longer refuses it.
        let email = Manifest::parse(
            "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
             [[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n",
        )
        .expect("group email template parses");
        assert_eq!(email.triggers.email.len(), 1);

        // Neither / both is rejected.
        Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
        Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
            .expect_err("both [app] and [group]");
    }

    #[test]
    fn project_block_parses_independently_of_node_kind() {
        // §7: [project] is orthogonal to the app/group XOR — either node kind
        // may declare the owning project.
        let a = Manifest::parse(
            "[project]\nslug = \"platform\"\nname = \"Platform\"\n\n\
             [app]\nslug = \"web\"\nname = \"Web\"\n",
        )
        .expect("[app] + [project] parses");
        assert_eq!(a.project.as_ref().unwrap().slug, "platform");
        assert_eq!(
            a.project.as_ref().unwrap().name.as_deref(),
            Some("Platform")
        );

        let g = Manifest::parse(
            "[project]\nslug = \"platform\"\n\n[group]\nslug = \"acme\"\nname = \"ACME\"\n",
        )
        .expect("[group] + [project] parses");
        assert_eq!(g.project.as_ref().unwrap().slug, "platform");
        assert!(
            g.project.as_ref().unwrap().name.is_none(),
            "name is optional"
        );

        // Absent [project] → None (backward-compatible).
        assert!(Manifest::parse("[app]\nslug=\"w\"\nname=\"W\"\n")
            .unwrap()
            .project
            .is_none());
        // Unknown key inside [project] is rejected (deny_unknown_fields).
        Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
            .expect_err("unknown [project] key rejected");

        // §6/§7 M2: the optional attach-point `parent_group`.
        let m = Manifest::parse(
            "[project]\nslug = \"p\"\nparent_group = \"acme\"\n\n\
             [group]\nslug = \"team\"\nname = \"T\"\n",
        )
        .expect("parent_group parses");
        assert_eq!(m.project.unwrap().parent_group.as_deref(), Some("acme"));
    }

    #[test]
    fn unknown_key_in_an_entry_spec_is_rejected() {
        // Entry specs (scripts/routes/triggers) are `deny_unknown_fields`, so a
        // typo inside a `[[…]]` entry is a hard error, not silently dropped.
        // A misspelled trigger field (`collection` for `collection_glob`):
        Manifest::parse(
            "[app]\nslug = \"a\"\nname = \"A\"\n\n\
             [[triggers.kv]]\nscript = \"h\"\ncollection = \"c\"\n",
        )
        .expect_err("a misspelled [[triggers.kv]] field must be rejected");
        // A misspelled route field:
        Manifest::parse(
            "[app]\nslug = \"a\"\nname = \"A\"\n\n\
             [[routes]]\nscript = \"h\"\nhost_kind = \"any\"\npath_kind = \"exact\"\n\
             path = \"/x\"\nmethid = \"GET\"\n",
        )
        .expect_err("a misspelled [[routes]] field must be rejected");
        // The correctly-spelled form still parses.
        Manifest::parse(
            "[app]\nslug = \"a\"\nname = \"A\"\n\n\
             [[triggers.kv]]\nscript = \"h\"\ncollection_glob = \"c\"\n",
        )
        .expect("a correct [[triggers.kv]] entry parses");
    }

    #[test]
    fn overlay_vars_override_base_per_key() {
        let mut base = sample();
        base.vars
            .insert("region".into(), toml::Value::String("eu".into()));
        base.vars
            .insert("tier".into(), toml::Value::String("base".into()));
        let overlay: ManifestOverlay =
            toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
        base.apply_overlay(overlay);
        // overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
        assert_eq!(base.vars["region"], toml::Value::String("us".into()));
        assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
        assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
    }
}
