//! Aether — the capability + agent source (L0).
//!
//! Reads the two non-markdown artifacts of the canonical Aether/Veris payload that Aun (docs) and
//! Glyph (code) don't touch:
//!
//! - `capabilities/*.yaml` — a *capability claim*: `id`, `subject: skill.X`, `statement`, plus
//!   lifecycle fields (`check`, `invalidated_by`, `scope`) that belong to Veris, not here.
//! - `agents/<name>/agent.toml` — an agent definition: `name`, `description`, `skills`, `tools`,
//!   `permissions`.
//!
//! ## Division of labor: Eidos indexes, Veris mints
//!
//! Veris *mints* capability claims — it runs the `check` and tracks the proven → stale → contradicted
//! lifecycle (`veris-core`'s `CapabilityTrustState`). Aether-source only **indexes** the declared
//! capability so it is groundable and graph-linked to its subject skill. We therefore parse only the
//! flat identity/description fields and deliberately ignore `check`/`invalidated_by` (lifecycle).
//!
//! ## Parsing
//!
//! The indexed subset is flat (`key: value` scalars and simple lists), so we hand-roll a minimal
//! reader rather than pull in a YAML/TOML dependency — matching Aun's hand-rolled frontmatter and
//! keeping the crate's lean dependency set (serde only). Nested blocks (`check:`, `scope:`) are
//! skipped, not parsed.

use std::path::{Path, PathBuf};

use eidos_kernel::schema::{Edge, EdgeBasis, Kind, Node};

use crate::source::{Source, SourceOutput};

/// A capability claim is *about* its subject skill/agent (the read analogue of authorship).
pub const DESCRIBES: &str = "describes";
/// An agent declares it *uses* a skill.
pub const USES: &str = "uses";

/// The Aether source: compiles capability claims and agent definitions under a configured root.
#[derive(Debug, Clone)]
pub struct Aether {
    mode: AetherMode,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AetherMode {
    /// Back-compatible package mode: `capabilities/*.yaml` + any `agent.toml` under the root.
    Package,
    /// Back-compatible agent scan only: any `agent.toml` under the root.
    PackageAgents,
    /// Back-compatible capability scan only: `capabilities/*.yaml` under the root.
    PackageCapabilities,
    /// EKF explicit capability root: every `*.yaml` under this root is a capability declaration.
    Capabilities,
    /// EKF explicit agent root: every `agent.toml` under this root is an agent declaration.
    Agents,
}

impl Aether {
    pub fn new() -> Self {
        Self {
            mode: AetherMode::Package,
        }
    }

    pub fn capabilities_root() -> Self {
        Self {
            mode: AetherMode::Capabilities,
        }
    }

    pub fn agents_root() -> Self {
        Self {
            mode: AetherMode::Agents,
        }
    }

    pub fn package_agents() -> Self {
        Self {
            mode: AetherMode::PackageAgents,
        }
    }

    pub fn package_capabilities() -> Self {
        Self {
            mode: AetherMode::PackageCapabilities,
        }
    }
}

impl Default for Aether {
    fn default() -> Self {
        Self::new()
    }
}

impl Source for Aether {
    fn name(&self) -> &str {
        "aether"
    }

    fn compile(&self, root: &Path) -> SourceOutput {
        let mut files = Vec::new();
        walk(root, &mut files);
        files.sort();

        let mut nodes = Vec::new();
        let mut edges = Vec::new();

        for path in &files {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            let in_capabilities_dir = path
                .parent()
                .and_then(|p| p.file_name())
                .and_then(|n| n.to_str())
                == Some("capabilities");

            if self.mode.includes_agents() && name == "agent.toml" {
                if let Some((node, mut agent_edges)) = agent_node(path) {
                    nodes.push(node);
                    edges.append(&mut agent_edges);
                }
            } else if self.mode.includes_capabilities(in_capabilities_dir)
                && name.ends_with(".yaml")
                && let Some((node, mut cap_edges)) = capability_node(path)
            {
                nodes.push(node);
                edges.append(&mut cap_edges);
            }
        }

        SourceOutput {
            nodes,
            edges,
            pending_edges: Vec::new(),
            diagnostics: Vec::new(),
        }
    }
}

impl AetherMode {
    fn includes_agents(self) -> bool {
        matches!(
            self,
            AetherMode::Package | AetherMode::PackageAgents | AetherMode::Agents
        )
    }

    fn includes_capabilities(self, in_capabilities_dir: bool) -> bool {
        match self {
            AetherMode::Package | AetherMode::PackageCapabilities => in_capabilities_dir,
            AetherMode::Capabilities => true,
            AetherMode::PackageAgents | AetherMode::Agents => false,
        }
    }
}

/// Walk the directory tree collecting all files, with the same exclusions as the other sources
/// (hidden dirs incl. `.eidos`, `target`, `node_modules`).
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            if let Some(n) = path.file_name().and_then(|n| n.to_str())
                && (n.starts_with('.') || n == "target" || n == "node_modules")
            {
                continue;
            }
            walk(&path, out);
        } else if path.is_file() {
            out.push(path);
        }
    }
}

/// Build a capability node + an edge to each `skill.*`/`agent.*` token in its `subject`.
fn capability_node(path: &Path) -> Option<(Node, Vec<Edge>)> {
    let text = std::fs::read_to_string(path).ok()?;
    let fields = parse_flat(&text);

    // `id` is the canonical name; fall back to the file stem.
    let id = fields.scalar("id").map_or_else(
        || {
            path.file_stem()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned()
        },
        str::to_string,
    );
    if id.is_empty() {
        return None;
    }
    let subject = fields.scalar("subject").unwrap_or("").to_string();
    let statement = fields.scalar("statement").unwrap_or("").to_string();

    let node_id = format!("capability.{}", slug(&id));
    let mut aliases = vec![id.clone()];
    if !subject.is_empty() {
        aliases.push(subject.clone());
    }
    let mut tags = fields.list("tags");
    // pillar/layer/authority are useful category surfaces; fold them into tags (haystack only).
    for k in ["pillar", "layer", "authority"] {
        if let Some(v) = fields.scalar(k) {
            tags.push(v.to_string());
        }
    }

    let node = Node {
        id: node_id.clone(),
        kind: Kind::Doc,
        subkind: Some("capability".to_string()),
        title: id,
        summary: statement,
        aliases,
        tags,
        query_examples: Vec::new(),
        source_files: vec![path.to_string_lossy().into_owned()],
        span: None,
        partition: None,
    };

    // Edge to each referenced subject node (`skill.X`, `agent.X`). The target node is produced by
    // another source (Aun); the merged graph resolves it.
    let mut edges = Vec::new();
    for target in subject_targets(&subject) {
        edges.push(Edge {
            from: node_id.clone(),
            to: target,
            relation: DESCRIBES.into(),
            evidence: "capability subject".into(),
            basis: EdgeBasis::Resolved,
            ..Default::default()
        });
    }
    Some((node, edges))
}

/// Build an agent node + a `uses` edge to each declared skill.
fn agent_node(path: &Path) -> Option<(Node, Vec<Edge>)> {
    let text = std::fs::read_to_string(path).ok()?;
    let fields = parse_flat(&text);

    let name = fields
        .scalar("name")
        .map(str::to_string)
        .or_else(|| {
            path.parent()
                .and_then(|p| p.file_name())
                .map(|n| n.to_string_lossy().into_owned())
        })
        .unwrap_or_default();
    if name.is_empty() {
        return None;
    }
    let description = fields.scalar("description").unwrap_or("").to_string();
    let skills = fields.list("skills");
    let mut tags = fields.list("tools");
    tags.extend(fields.list("permissions"));

    let node_id = format!("agent.{}", slug(&name));
    let node = Node {
        id: node_id.clone(),
        kind: Kind::Agent,
        subkind: Some("agent".to_string()),
        title: name.clone(),
        summary: description,
        aliases: vec![name],
        tags,
        query_examples: Vec::new(),
        source_files: vec![path.to_string_lossy().into_owned()],
        span: None,
        partition: None,
    };

    let edges = skills
        .iter()
        .map(|s| Edge {
            from: node_id.clone(),
            to: format!("skill.{}", slug(s)),
            relation: USES.into(),
            evidence: "agent skill binding".into(),
            basis: EdgeBasis::Resolved,
            ..Default::default()
        })
        .collect();
    Some((node, edges))
}

/// Extract `skill.*` / `agent.*` node-id targets from a capability `subject` string. Handles both
/// the simple form (`skill.frontend-ux`) and the compound form
/// (`agent.example-agent uses skill.example-skill`).
fn subject_targets(subject: &str) -> Vec<String> {
    let mut out = Vec::new();
    for token in subject.split_whitespace() {
        let token = token
            .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-' && c != '_');
        if let Some(rest) = token.strip_prefix("skill.") {
            out.push(format!("skill.{}", slug(rest)));
        } else if let Some(rest) = token.strip_prefix("agent.") {
            out.push(format!("agent.{}", slug(rest)));
        }
    }
    out.sort();
    out.dedup();
    out
}

// ─── a minimal flat key/value reader (YAML- and TOML-subset) ─────────────────────────────────────

/// Parsed flat fields: top-level `key: value` (YAML) or `key = value` (TOML) scalars and simple
/// lists (`[a, b]` inline, or YAML block `- item`). Nested maps are skipped.
#[derive(Default)]
struct Flat {
    scalars: Vec<(String, String)>,
    lists: Vec<(String, Vec<String>)>,
}

impl Flat {
    fn scalar(&self, key: &str) -> Option<&str> {
        self.scalars
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.as_str())
    }
    fn list(&self, key: &str) -> Vec<String> {
        // An inline list is stored under `lists`; a block list is assembled below in parse_flat.
        self.lists
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.clone())
            .unwrap_or_default()
    }
}

fn parse_flat(text: &str) -> Flat {
    let mut flat = Flat::default();
    let mut block_list_key: Option<String> = None;
    let mut block_items: Vec<String> = Vec::new();

    let flush_block = |flat: &mut Flat, key: &mut Option<String>, items: &mut Vec<String>| {
        if let Some(k) = key.take()
            && !items.is_empty()
        {
            flat.lists.push((k, std::mem::take(items)));
        }
    };

    for raw in text.lines() {
        let line = raw.split('#').next().unwrap_or(raw); // strip trailing comments
        let trimmed = line.trim_end();
        if trimmed.trim().is_empty() {
            continue;
        }

        // A YAML block-list item belongs to the most recent `key:` with an empty value.
        let lead = line.trim_start();
        if block_list_key.is_some() && lead.starts_with("- ") {
            block_items.push(strip_quotes(lead[2..].trim()));
            continue;
        }
        // A new top-level key ends any open block list. Only top-level (no indent) keys count;
        // indented keys are nested-map contents we intentionally skip.
        flush_block(&mut flat, &mut block_list_key, &mut block_items);
        if line.starts_with(char::is_whitespace) {
            continue; // nested content under a skipped map
        }

        // `key: value` (YAML) or `key = value` (TOML).
        let (key, val) = match split_kv(trimmed) {
            Some(kv) => kv,
            None => continue,
        };
        let val = val.trim();
        if val.is_empty() {
            // Possibly the head of a YAML block list (items follow) or a nested map (skipped).
            block_list_key = Some(key.to_string());
            block_items.clear();
        } else if let Some(items) = parse_inline_list(val) {
            flat.lists.push((key.to_string(), items));
        } else {
            flat.scalars.push((key.to_string(), strip_quotes(val)));
        }
    }
    flush_block(&mut flat, &mut block_list_key, &mut block_items);
    flat
}

/// Split on the first `:` (YAML) or `=` (TOML), returning the trimmed key and the raw value.
fn split_kv(line: &str) -> Option<(&str, &str)> {
    let colon = line.find(':');
    let eq = line.find('=');
    let idx = match (colon, eq) {
        (Some(c), Some(e)) => c.min(e),
        (Some(c), None) => c,
        (None, Some(e)) => e,
        (None, None) => return None,
    };
    let key = line[..idx].trim();
    if key.is_empty() || key.contains(char::is_whitespace) {
        return None;
    }
    Some((key, &line[idx + 1..]))
}

/// Parse an inline list `[a, b, c]` (both YAML and TOML use this form). Returns `None` if `val`
/// isn't bracketed.
fn parse_inline_list(val: &str) -> Option<Vec<String>> {
    let inner = val.strip_prefix('[')?.strip_suffix(']')?;
    Some(
        inner
            .split(',')
            .map(|s| strip_quotes(s.trim()))
            .filter(|s| !s.is_empty())
            .collect(),
    )
}

fn strip_quotes(s: &str) -> String {
    let s = s.trim();
    s.strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
        .unwrap_or(s)
        .to_string()
}

/// Lowercase, collapse non-alphanumerics to `-`, trim — matches Aun's skill-id slug so capability
/// `subject: skill.X` and agent `skills: [X]` resolve to the real `skill.<slug>` node ids.
fn slug(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_dash = false;
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

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

    fn write(path: &Path, body: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, body).unwrap();
    }

    /// A capability YAML (with nested `check`/`scope` blocks that must be SKIPPED) becomes a
    /// groundable Doc(subkind=capability) node + a `describes` edge to its subject skill.
    #[test]
    fn capability_yaml_becomes_node_with_subject_edge() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("capabilities/frontend-build.yaml"),
            "id: frontend-build\nsubject: skill.frontend-ux\nstatement: Can create a runnable frontend.\ncheck:\n  argv: [\"python\", \"-c\", \"print(1)\"]\n  expect_exit: 0\nscope:\n  tools:\n    - read\ntags:\n  - frontend\n  - ux\n",
        );

        let out = Aether::new().compile(tmp.path());
        let cap = out
            .nodes
            .iter()
            .find(|n| n.subkind.as_deref() == Some("capability"))
            .expect("capability node");
        assert_eq!(cap.id, "capability.frontend-build");
        assert_eq!(cap.kind, Kind::Doc);
        assert_eq!(cap.summary, "Can create a runnable frontend.");
        assert!(cap.tags.contains(&"frontend".to_string()) && cap.tags.contains(&"ux".to_string()));
        // nested check.argv must NOT have leaked into any field
        assert!(!cap.summary.contains("python"));

        let edge = out
            .edges
            .iter()
            .find(|e| e.relation == DESCRIBES)
            .expect("describes edge");
        assert_eq!(edge.from, "capability.frontend-build");
        assert_eq!(edge.to, "skill.frontend-ux");
        assert_eq!(edge.basis, EdgeBasis::Resolved);
    }

    /// An agent.toml becomes a Kind::Agent node + a `uses` edge per declared skill; tools/permissions
    /// land in tags.
    #[test]
    fn agent_toml_becomes_node_with_skill_edges() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("agents/frontend-builder/agent.toml"),
            "name = \"frontend-builder\"\ndescription = \"Builds local frontend artifacts.\"\nskills = [\"frontend-ux\", \"browser-smoke\"]\ntools = [\"read\", \"write\"]\npermissions = [\"read\"]\n",
        );

        let out = Aether::new().compile(tmp.path());
        let agent = out
            .nodes
            .iter()
            .find(|n| n.kind == Kind::Agent)
            .expect("agent node");
        assert_eq!(agent.id, "agent.frontend-builder");
        assert_eq!(agent.summary, "Builds local frontend artifacts.");
        assert!(agent.tags.contains(&"write".to_string()));

        let mut uses: Vec<_> = out
            .edges
            .iter()
            .filter(|e| e.relation == USES)
            .map(|e| e.to.as_str())
            .collect();
        uses.sort();
        assert_eq!(uses, vec!["skill.browser-smoke", "skill.frontend-ux"]);
        assert!(
            out.edges
                .iter()
                .filter(|e| e.relation == USES)
                .all(|e| e.basis == EdgeBasis::Resolved),
            "agent.toml skill bindings are explicit package declarations, not lexical guesses"
        );
    }

    /// The compound agent-skill subject form must still produce the skill edge.
    #[test]
    fn compound_subject_resolves_skill_target() {
        let targets = subject_targets("agent.example-agent uses skill.example-skill");
        assert!(targets.contains(&"skill.example-skill".to_string()));
        assert!(targets.contains(&"agent.example-agent".to_string()));
    }

    /// `.yaml` files OUTSIDE a `capabilities/` dir are ignored (don't grab random config yaml).
    #[test]
    fn ignores_yaml_outside_capabilities_dir() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("config/random.yaml"),
            "id: nope\nsubject: skill.x\n",
        );
        let out = Aether::new().compile(tmp.path());
        assert!(out.nodes.is_empty(), "non-capability yaml must be ignored");
    }

    #[test]
    fn explicit_capability_root_indexes_yaml_without_capabilities_dir_name() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("caps/frontend-build.yaml"),
            "id: frontend-build\nsubject: skill.frontend-ux\nstatement: Can create a runnable frontend.\n",
        );

        let out = Aether::capabilities_root().compile(&tmp.path().join("caps"));

        assert!(
            out.nodes
                .iter()
                .any(|n| n.id == "capability.frontend-build"),
            "explicit EKF capability roots must not require a capabilities/ directory name"
        );
    }

    #[test]
    fn explicit_agent_root_does_not_index_capability_yaml() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("agents/frontend-builder/agent.toml"),
            "name = \"frontend-builder\"\ndescription = \"Builds frontends.\"\n",
        );
        write(
            &tmp.path().join("agents/frontend-builder/cap.yaml"),
            "id: should-not-load\nsubject: skill.frontend-ux\nstatement: no\n",
        );

        let out = Aether::agents_root().compile(&tmp.path().join("agents"));

        assert!(out.nodes.iter().any(|n| n.id == "agent.frontend-builder"));
        assert!(
            out.nodes
                .iter()
                .all(|n| n.id != "capability.should-not-load"),
            "agent roots should not treat nearby YAML as capabilities"
        );
    }
}
