//! Declarative agents — in-daemon agents that need no external process.
//!
//! A [`DeclarativeAgentSpec`] is pure data: an identity (system prompt), a
//! tool **allowlist** (names of tools the daemon already exposes), an optional
//! deny list, a standing goal, and scenarios (test cases). The daemon runs it
//! with a generic model→tool loop — there is no command to spawn, so a
//! non-developer never installs Node/Python/anything.
//!
//! This is a **parallel registry** to [`crate::supervisor`], deliberately NOT
//! an extension of `supervisor::AgentSpec`: that type's whole security model is
//! the absolute-executable-path validation in `Supervisor::upsert` (the
//! 2026-05 audit's control). A declarative agent has no command, so it must
//! never travel through that path. `agents.list` read-merges declarative
//! entries (tagged `kind:"declarative"`) for the unified host view.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// A test case for a declarative agent: run it on `input`, the output must
/// contain `expect` (a stable substring — not an exact match, so the contract
/// tolerates benign model variation).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scenario {
    pub input: String,
    pub expect: String,
}

/// A declarative, in-daemon agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeclarativeAgentSpec {
    /// Filename-safe id (derived from the project slug).
    pub id: String,
    pub name: String,
    /// System prompt — who the agent is and how it behaves.
    pub identity: String,
    /// Allowlist of tool names the agent may call. The daemon exposes ONLY
    /// these to the model; anything else is invisible. Empty = no tools
    /// (a pure-reasoning agent).
    #[serde(default)]
    pub tools: Vec<String>,
    /// Extra hard denials layered under the allowlist (belt and suspenders).
    #[serde(default)]
    pub denied_tools: Vec<String>,
    /// The agent's persistent objective, prepended to every run.
    #[serde(default)]
    pub standing_goal: String,
    /// Acceptance scenarios — the contract the coder→agent loop drives to green.
    #[serde(default)]
    pub scenarios: Vec<Scenario>,
    /// Lifecycle toggle. A disabled agent stays registered but won't run.
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_true() -> bool {
    true
}

impl DeclarativeAgentSpec {
    /// Structural problems that make a spec unusable. Empty = valid.
    pub fn validate(&self) -> Vec<String> {
        let mut issues = Vec::new();
        if !is_filename_safe(&self.id) {
            issues.push(format!("invalid agent id (alphanumeric + -_.): {:?}", self.id));
        }
        if self.name.trim().is_empty() {
            issues.push("agent name is empty".into());
        }
        if self.identity.trim().is_empty() {
            issues.push("agent identity (system prompt) is empty".into());
        }
        issues
    }
}

fn is_filename_safe(id: &str) -> bool {
    !id.is_empty()
        && id != "."
        && id != ".."
        && id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}

/// File-backed registry of declarative agents at `~/.car/declagents.json`
/// (`CAR_DECLAGENTS_PATH` overrides for tests/embedders). Atomic write-through
/// — the on-disk JSON is authoritative across restarts, mirroring the
/// supervisor manifest's hygiene.
pub struct DeclRegistry {
    path: PathBuf,
}

impl DeclRegistry {
    /// `~/.car/declagents.json` (or `CAR_DECLAGENTS_PATH`).
    pub fn user_default() -> Result<Self, String> {
        if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
            return Ok(Self { path: PathBuf::from(p) });
        }
        let home = std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
        Ok(Self {
            path: PathBuf::from(home).join(".car").join("declagents.json"),
        })
    }

    pub fn at(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
        std::fs::read_to_string(&self.path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
        }
        let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
        // Atomic: write a sibling temp then rename over the target.
        let tmp = self.path.with_extension("json.tmp");
        std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
        std::fs::rename(&tmp, &self.path).map_err(|e| format!("rename into {}: {e}", self.path.display()))
    }

    /// Insert or replace by id. Validates the spec first.
    pub fn upsert(&self, spec: DeclarativeAgentSpec) -> Result<(), String> {
        let issues = spec.validate();
        if !issues.is_empty() {
            return Err(format!("invalid declarative agent: {}", issues.join("; ")));
        }
        let mut all = self.read_all();
        if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
            *existing = spec;
        } else {
            all.push(spec);
        }
        self.write_all(&all)
    }

    pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
        self.read_all()
    }

    pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
        self.read_all().into_iter().find(|s| s.id == id)
    }

    pub fn remove(&self, id: &str) -> Result<bool, String> {
        let mut all = self.read_all();
        let before = all.len();
        all.retain(|s| s.id != id);
        let removed = all.len() != before;
        if removed {
            self.write_all(&all)?;
        }
        Ok(removed)
    }

    pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
        let mut all = self.read_all();
        let spec = all
            .iter_mut()
            .find(|s| s.id == id)
            .ok_or_else(|| format!("no declarative agent '{id}'"))?;
        spec.enabled = on;
        self.write_all(&all)
    }
}

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

    fn spec(id: &str) -> DeclarativeAgentSpec {
        DeclarativeAgentSpec {
            id: id.into(),
            name: "Test".into(),
            identity: "You are a test agent.".into(),
            tools: vec!["read_file".into()],
            denied_tools: vec![],
            standing_goal: "be helpful".into(),
            scenarios: vec![Scenario { input: "hi".into(), expect: "ok".into() }],
            enabled: true,
        }
    }

    fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
        let dir = tempfile::tempdir().unwrap();
        let reg = DeclRegistry::at(dir.path().join("declagents.json"));
        (dir, reg)
    }

    #[test]
    fn upsert_list_get_remove_round_trip() {
        let (_d, reg) = temp_registry();
        reg.upsert(spec("email-bot")).unwrap();
        reg.upsert(spec("note-taker")).unwrap();
        assert_eq!(reg.list().len(), 2);
        assert_eq!(reg.get("email-bot").unwrap().name, "Test");
        // Upsert replaces, doesn't duplicate.
        let mut updated = spec("email-bot");
        updated.name = "Renamed".into();
        reg.upsert(updated).unwrap();
        assert_eq!(reg.list().len(), 2);
        assert_eq!(reg.get("email-bot").unwrap().name, "Renamed");
        assert!(reg.remove("email-bot").unwrap());
        assert!(!reg.remove("email-bot").unwrap());
        assert_eq!(reg.list().len(), 1);
    }

    #[test]
    fn set_enabled_toggles() {
        let (_d, reg) = temp_registry();
        reg.upsert(spec("a")).unwrap();
        reg.set_enabled("a", false).unwrap();
        assert!(!reg.get("a").unwrap().enabled);
        reg.set_enabled("a", true).unwrap();
        assert!(reg.get("a").unwrap().enabled);
        assert!(reg.set_enabled("missing", true).is_err());
    }

    #[test]
    fn invalid_spec_is_rejected() {
        let (_d, reg) = temp_registry();
        let mut bad = spec("../escape");
        assert!(reg.upsert(bad.clone()).is_err());
        bad.id = "ok".into();
        bad.identity = "  ".into();
        assert!(reg.upsert(bad).is_err());
    }

    #[test]
    fn persists_across_handles() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("declagents.json");
        DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
        // A fresh handle reads the same on-disk state.
        assert!(DeclRegistry::at(&path).get("persist").is_some());
    }
}
