//! AINL graph node types - the vocabulary of agent memory.
//!
//! Core memory types: Episode (episodic), Semantic, Procedural, Persona, Trajectory (execution trace),
//! typed **Failure** nodes for operator recall, plus `RuntimeState` for ainl-runtime session counters.
//! Designed to be standalone (zero ArmaraOS deps) yet compatible with
//! OrchestrationTraceEvent serialization.

use ainl_contracts::{TrajectoryOutcome, TrajectoryStep};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::fmt;
use uuid::Uuid;

fn deserialize_updated_at<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
    D: Deserializer<'de>,
{
    use serde::de::{self, Visitor};

    struct TsVisitor;
    impl<'de> Visitor<'de> for TsVisitor {
        type Value = i64;

        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
            f.write_str("unix timestamp or RFC3339 string")
        }

        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
            Ok(v)
        }

        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
            i64::try_from(v).map_err(de::Error::custom)
        }

        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(v) {
                return Ok(dt.timestamp());
            }
            v.parse::<i64>().map_err(de::Error::custom)
        }

        fn visit_string<E: de::Error>(self, v: String) -> Result<i64, E> {
            self.visit_str(&v)
        }
    }

    deserializer.deserialize_any(TsVisitor)
}

/// Coarse node kind for store queries (matches `node_type` column values).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AinlNodeKind {
    Episode,
    Semantic,
    Procedural,
    Persona,
    /// Agent-scoped persisted session counters / persona cache (see [`RuntimeStateNode`]).
    RuntimeState,
    /// Step-level execution trace, typically linked to an [`EpisodicNode`].
    Trajectory,
    /// Typed failure (e.g. loop guard) for search / dashboards.
    Failure,
}

impl AinlNodeKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Episode => "episode",
            Self::Semantic => "semantic",
            Self::Procedural => "procedural",
            Self::Persona => "persona",
            Self::RuntimeState => "runtime_state",
            Self::Trajectory => "trajectory",
            Self::Failure => "failure",
        }
    }
}

/// Memory category aligned with the four memory families (episodic ↔ `Episode` nodes).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryCategory {
    Persona,
    Semantic,
    Episodic,
    Procedural,
    /// Agent-scoped runtime session counters / cache hints (persisted by `ainl-runtime`).
    RuntimeState,
    /// Recorded tool/adapter trajectory for learning and replay.
    Trajectory,
    /// Operator-visible failure substrate (loop guard, runtime gates, …).
    Failure,
}

impl MemoryCategory {
    pub fn from_node_type(node_type: &AinlNodeType) -> Self {
        match node_type {
            AinlNodeType::Episode { .. } => MemoryCategory::Episodic,
            AinlNodeType::Semantic { .. } => MemoryCategory::Semantic,
            AinlNodeType::Procedural { .. } => MemoryCategory::Procedural,
            AinlNodeType::Persona { .. } => MemoryCategory::Persona,
            AinlNodeType::RuntimeState { .. } => MemoryCategory::RuntimeState,
            AinlNodeType::Trajectory { .. } => MemoryCategory::Trajectory,
            AinlNodeType::Failure { .. } => MemoryCategory::Failure,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersonaLayer {
    #[default]
    Base,
    Delta,
    Injection,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersonaSource {
    SystemDefault,
    #[default]
    UserConfigured,
    Evolved,
    Feedback,
    Injection,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sentiment {
    Positive,
    Neutral,
    Negative,
    Mixed,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcedureType {
    #[default]
    ToolSequence,
    ResponsePattern,
    WorkflowStep,
    BehavioralRule,
}

/// One strength adjustment on a persona trait (evolution / provenance).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StrengthEvent {
    pub delta: f32,
    pub reason: String,
    pub episode_id: String,
    pub timestamp: u64,
}

fn default_importance_score() -> f32 {
    0.5
}

fn default_semantic_confidence() -> f32 {
    0.7
}

fn default_decay_eligible() -> bool {
    true
}

fn default_success_rate() -> f32 {
    0.5
}

fn default_procedural_prompt_eligible() -> bool {
    true
}

fn default_strength_floor() -> f32 {
    0.0
}

/// Canonical persona payload (flattened under `AinlNodeType::Persona` in JSON).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PersonaNode {
    pub trait_name: String,
    pub strength: f32,
    #[serde(default)]
    pub learned_from: Vec<Uuid>,
    #[serde(default)]
    pub layer: PersonaLayer,
    #[serde(default)]
    pub source: PersonaSource,
    #[serde(default = "default_strength_floor")]
    pub strength_floor: f32,
    #[serde(default)]
    pub locked: bool,
    #[serde(default)]
    pub relevance_score: f32,
    #[serde(default)]
    pub provenance_episode_ids: Vec<String>,
    #[serde(default)]
    pub evolution_log: Vec<StrengthEvent>,
    /// Optional axis-evolution bundle (`ainl-persona`); omitted in JSON → empty map.
    #[serde(default)]
    pub axis_scores: HashMap<String, f32>,
    #[serde(default)]
    pub evolution_cycle: u32,
    /// ISO-8601 timestamp of last persona evolution pass.
    #[serde(default)]
    pub last_evolved: String,
    /// Redundant copy of owning agent id (mirrors `AinlMemoryNode.agent_id` for payload consumers).
    #[serde(default)]
    pub agent_id: String,
    /// Soft labels: axes above the high-spectrum threshold, not discrete classes.
    #[serde(default)]
    pub dominant_axes: Vec<String>,
}

/// Semantic / factual memory payload.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct SemanticNode {
    pub fact: String,
    #[serde(default = "default_semantic_confidence")]
    pub confidence: f32,
    pub source_turn_id: Uuid,
    #[serde(default)]
    pub topic_cluster: Option<String>,
    #[serde(default)]
    pub source_episode_id: String,
    #[serde(default)]
    pub contradiction_ids: Vec<String>,
    #[serde(default)]
    pub last_referenced_at: u64,
    /// How many times this node has been retrieved from the store.
    /// Managed by the recall path only — never written by extractors.
    #[serde(default)]
    pub reference_count: u32,
    #[serde(default = "default_decay_eligible")]
    pub decay_eligible: bool,
    /// Optional tag hints for analytics / persona (`ainl-persona`); omitted → empty.
    #[serde(default)]
    pub tags: Vec<String>,
    /// How many times this exact fact has recurred across separate extraction events.
    /// Written by `graph_extractor` when the same fact is observed again.
    ///
    /// Do **not** use `reference_count` as a substitute: that field tracks retrieval frequency,
    /// not extraction recurrence. They measure different things. `graph_extractor` (Prompt 2)
    /// must write `recurrence_count` directly; persona / domain extractors gate on this field only.
    #[serde(default)]
    pub recurrence_count: u32,
    /// `reference_count` snapshot from the last graph-extractor pass (JSON key `_last_ref_snapshot`).
    #[serde(rename = "_last_ref_snapshot", default)]
    pub last_ref_snapshot: u32,
}

/// Episodic memory payload (one turn / moment).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct EpisodicNode {
    pub turn_id: Uuid,
    pub timestamp: i64,
    #[serde(default)]
    pub tool_calls: Vec<String>,
    #[serde(default)]
    pub delegation_to: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trace_event: Option<serde_json::Value>,
    #[serde(default)]
    pub turn_index: u32,
    #[serde(default)]
    pub user_message_tokens: u32,
    #[serde(default)]
    pub assistant_response_tokens: u32,
    /// Preferred list of tools for analytics; mirrors `tool_calls` when not set explicitly.
    #[serde(default)]
    pub tools_invoked: Vec<String>,
    /// Persona signal names emitted this turn (`Vec`, never `Option`). Omitted JSON → `[]`.
    /// Serialized even when empty (no `skip_serializing_if`). Backfill: `read_node` → patch → `write_node`.
    #[serde(default)]
    pub persona_signals_emitted: Vec<String>,
    #[serde(default)]
    pub sentiment: Option<Sentiment>,
    #[serde(default)]
    pub flagged: bool,
    #[serde(default)]
    pub conversation_id: String,
    #[serde(default)]
    pub follows_episode_id: Option<String>,
    /// Optional raw user message for offline extractors (`ainl-graph-extractor`); omitted unless set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user_message: Option<String>,
    /// Optional assistant reply text for offline extractors; omitted unless set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub assistant_response: Option<String>,
    /// Deterministic semantic category tags for this episode (e.g. from `ainl-semantic-tagger` / tool sequence).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    /// Coarse cognitive gate from the LLM completion that produced this episode: "pass" / "warn" / "fail".
    /// `None` when the provider did not return logprobs (Anthropic, Ollama, etc.).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vitals_gate: Option<String>,
    /// Fine-grained cognitive phase + confidence, e.g. `"reasoning:0.69"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vitals_phase: Option<String>,
    /// Scalar trust score in [0, 1]. Higher = more confident / lower entropy.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vitals_trust: Option<f32>,
}

impl EpisodicNode {
    /// Effective tool list: `tools_invoked` if non-empty, else `tool_calls`.
    pub fn effective_tools(&self) -> &[String] {
        if !self.tools_invoked.is_empty() {
            &self.tools_invoked
        } else {
            &self.tool_calls
        }
    }
}

/// Procedural memory payload.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ProceduralNode {
    pub pattern_name: String,
    #[serde(default)]
    pub compiled_graph: Vec<u8>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_sequence: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub confidence: Option<f32>,
    #[serde(default)]
    pub procedure_type: ProcedureType,
    #[serde(default)]
    pub trigger_conditions: Vec<String>,
    #[serde(default)]
    pub success_count: u32,
    #[serde(default)]
    pub failure_count: u32,
    #[serde(default = "default_success_rate")]
    pub success_rate: f32,
    #[serde(default)]
    pub last_invoked_at: u64,
    #[serde(default)]
    pub reinforcement_episode_ids: Vec<String>,
    #[serde(default)]
    pub suppression_episode_ids: Vec<String>,
    /// Graph-patch / refinement generation (`ainl-persona`); omitted JSON → 0 (skip persona extract until bumped).
    #[serde(default)]
    pub patch_version: u32,
    /// Optional fitness score in \[0,1\]; when absent, consumers may fall back to `success_rate`.
    #[serde(default)]
    pub fitness: Option<f32>,
    /// Declared read dependencies for the procedure (metadata-only hints).
    #[serde(default)]
    pub declared_reads: Vec<String>,
    /// When true, excluded from [`crate::GraphQuery::active_patches`] and skipped by patch dispatch.
    #[serde(default)]
    pub retired: bool,
    /// IR label for graph-patch identity (empty → runtimes may fall back to [`Self::pattern_name`]).
    #[serde(default)]
    pub label: String,
    /// Optional orchestration / turn correlation id (same namespace as episodic `trace_event.trace_id`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trace_id: Option<String>,
    /// How many times this normalized `tool_sequence` was reinforced (extractor / host merge).
    #[serde(default)]
    pub pattern_observation_count: u32,
    /// When true, the pattern may appear in graph-memory “SuggestedProcedure”-style output.
    /// Omitted in legacy JSON → `true` (behaves like older rows). New extractor candidates start
    /// `false` until [`crate::pattern_promotion::should_promote`].
    #[serde(default = "default_procedural_prompt_eligible")]
    pub prompt_eligible: bool,
}

impl ProceduralNode {
    pub fn recompute_success_rate(&mut self) {
        let total = self.success_count.saturating_add(self.failure_count);
        self.success_rate = if total == 0 {
            0.5
        } else {
            self.success_count as f32 / total as f32
        };
    }
}

/// Persisted runtime state for an agent session.
/// Written at end of each turn; read on `AinlRuntime::new` (ainl-runtime) to restore state.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeStateNode {
    pub agent_id: String,
    #[serde(default)]
    pub turn_count: u64,
    #[serde(default, alias = "last_extraction_turn")]
    pub last_extraction_at_turn: u64,
    /// Serialized persona contribution (JSON string value) — avoids re-deriving from graph on cold start.
    #[serde(default, alias = "last_persona_prompt")]
    pub persona_snapshot_json: Option<String>,
    #[serde(default, deserialize_with = "deserialize_updated_at")]
    pub updated_at: i64,
}

/// Execution trajectory: tool/adapter steps for learning and replay (schema from `ainl_contracts`).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct TrajectoryNode {
    /// Episode this trajectory augments.
    pub episode_id: Uuid,
    /// Unix seconds when the trajectory was recorded.
    pub recorded_at: i64,
    #[serde(default)]
    pub session_id: String,
    #[serde(default)]
    pub project_id: Option<String>,
    #[serde(default)]
    pub ainl_source_hash: Option<String>,
    pub outcome: TrajectoryOutcome,
    #[serde(default)]
    pub steps: Vec<TrajectoryStep>,
    #[serde(default)]
    pub duration_ms: u64,
    /// Optional end-of-episode frame snapshot (JSON) — e.g. vitals + compression summary.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub frame_vars: Option<serde_json::Value>,
    /// Optional host-learner “fitness” or improvement signal (e.g. vitals trust, Δ reward).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fitness_delta: Option<f32>,
}

/// Typed failure payload (persisted as `node_type = "failure"`).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct FailureNode {
    /// Unix seconds when the failure was recorded.
    pub recorded_at: i64,
    /// Origin label, e.g. `loop_guard:block` or `loop_guard:circuit_break`.
    pub source: String,
    #[serde(default)]
    pub tool_name: Option<String>,
    pub message: String,
    #[serde(default)]
    pub session_id: Option<String>,
}

/// Core AINL node types - the vocabulary of agent memory.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AinlNodeType {
    /// Episodic memory: what happened during an agent turn
    Episode {
        #[serde(flatten)]
        episodic: EpisodicNode,
    },

    /// Semantic memory: facts learned, with confidence
    Semantic {
        #[serde(flatten)]
        semantic: SemanticNode,
    },

    /// Procedural memory: reusable compiled workflow patterns
    Procedural {
        #[serde(flatten)]
        procedural: ProceduralNode,
    },

    /// Persona memory: traits learned over time
    Persona {
        #[serde(flatten)]
        persona: PersonaNode,
    },

    /// Runtime session state (turn counters, extraction cadence, persona cache snapshot).
    RuntimeState { runtime_state: RuntimeStateNode },

    /// Step-level execution trace linked to an episode (self-learning substrate).
    Trajectory {
        trajectory: TrajectoryNode,
    },

    /// Failure / guard outcome stored for recall (Phase 2 failure learning).
    Failure {
        failure: FailureNode,
    },
}

/// A node in the AINL memory graph
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct AinlMemoryNode {
    pub id: Uuid,
    pub memory_category: MemoryCategory,
    pub importance_score: f32,
    pub agent_id: String,
    /// Optional host workspace / repo key (when `AINL_MEMORY_PROJECT_SCOPE` is enabled).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,
    pub node_type: AinlNodeType,
    pub edges: Vec<AinlEdge>,
}

#[derive(Deserialize)]
struct AinlMemoryNodeWire {
    id: Uuid,
    #[serde(default)]
    memory_category: Option<MemoryCategory>,
    #[serde(default)]
    importance_score: Option<f32>,
    #[serde(default)]
    agent_id: Option<String>,
    #[serde(default)]
    project_id: Option<String>,
    node_type: AinlNodeType,
    #[serde(default)]
    edges: Vec<AinlEdge>,
}

impl From<AinlMemoryNodeWire> for AinlMemoryNode {
    fn from(w: AinlMemoryNodeWire) -> Self {
        let memory_category = w
            .memory_category
            .unwrap_or_else(|| MemoryCategory::from_node_type(&w.node_type));
        let importance_score = w.importance_score.unwrap_or_else(default_importance_score);
        Self {
            id: w.id,
            memory_category,
            importance_score,
            agent_id: w.agent_id.unwrap_or_default(),
            project_id: w
                .project_id
                .as_ref()
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty()),
            node_type: w.node_type,
            edges: w.edges,
        }
    }
}

impl<'de> Deserialize<'de> for AinlMemoryNode {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let w = AinlMemoryNodeWire::deserialize(deserializer)?;
        Ok(Self::from(w))
    }
}

/// Typed edge connecting memory nodes
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct AinlEdge {
    /// Target node ID
    pub target_id: Uuid,

    /// Edge label (e.g., "delegated_to", "learned_from", "caused_by")
    pub label: String,
}

impl AinlMemoryNode {
    fn base(
        memory_category: MemoryCategory,
        importance_score: f32,
        agent_id: String,
        node_type: AinlNodeType,
    ) -> Self {
        Self {
            id: Uuid::new_v4(),
            memory_category,
            importance_score,
            agent_id,
            project_id: None,
            node_type,
            edges: Vec::new(),
        }
    }

    /// Create a new episode node
    pub fn new_episode(
        turn_id: Uuid,
        timestamp: i64,
        tool_calls: Vec<String>,
        delegation_to: Option<String>,
        trace_event: Option<serde_json::Value>,
    ) -> Self {
        let tools_invoked = tool_calls.clone();
        let episodic = EpisodicNode {
            turn_id,
            timestamp,
            tool_calls,
            delegation_to,
            trace_event,
            turn_index: 0,
            user_message_tokens: 0,
            assistant_response_tokens: 0,
            tools_invoked,
            persona_signals_emitted: Vec::new(),
            sentiment: None,
            flagged: false,
            conversation_id: String::new(),
            follows_episode_id: None,
            user_message: None,
            assistant_response: None,
            tags: Vec::new(),
            vitals_gate: None,
            vitals_phase: None,
            vitals_trust: None,
        };
        Self::base(
            MemoryCategory::Episodic,
            default_importance_score(),
            String::new(),
            AinlNodeType::Episode { episodic },
        )
    }

    /// Create a new semantic fact node
    pub fn new_fact(fact: String, confidence: f32, source_turn_id: Uuid) -> Self {
        let semantic = SemanticNode {
            fact,
            confidence,
            source_turn_id,
            topic_cluster: None,
            source_episode_id: String::new(),
            contradiction_ids: Vec::new(),
            last_referenced_at: 0,
            reference_count: 0,
            decay_eligible: true,
            tags: Vec::new(),
            recurrence_count: 0,
            last_ref_snapshot: 0,
        };
        Self::base(
            MemoryCategory::Semantic,
            default_importance_score(),
            String::new(),
            AinlNodeType::Semantic { semantic },
        )
    }

    /// Create a new procedural pattern node
    pub fn new_pattern(pattern_name: String, compiled_graph: Vec<u8>) -> Self {
        let mut procedural = ProceduralNode {
            pattern_name,
            compiled_graph,
            tool_sequence: Vec::new(),
            confidence: None,
            procedure_type: ProcedureType::default(),
            trigger_conditions: Vec::new(),
            success_count: 0,
            failure_count: 0,
            success_rate: default_success_rate(),
            last_invoked_at: 0,
            reinforcement_episode_ids: Vec::new(),
            suppression_episode_ids: Vec::new(),
            patch_version: 1,
            fitness: None,
            declared_reads: Vec::new(),
            retired: false,
            label: String::new(),
            trace_id: None,
            pattern_observation_count: 0,
            prompt_eligible: true,
        };
        procedural.recompute_success_rate();
        Self::base(
            MemoryCategory::Procedural,
            default_importance_score(),
            String::new(),
            AinlNodeType::Procedural { procedural },
        )
    }

    /// Procedural node from a detected tool workflow (no compiled IR).
    pub fn new_procedural_tools(
        pattern_name: String,
        tool_sequence: Vec<String>,
        confidence: f32,
    ) -> Self {
        use crate::pattern_promotion;
        let c = confidence.clamp(0.0, 1.0);
        let ema0 = pattern_promotion::ema_fitness_update(None, c);
        let mut procedural = ProceduralNode {
            pattern_name,
            compiled_graph: Vec::new(),
            tool_sequence,
            confidence: Some(c),
            procedure_type: ProcedureType::ToolSequence,
            trigger_conditions: Vec::new(),
            success_count: 0,
            failure_count: 0,
            success_rate: default_success_rate(),
            last_invoked_at: 0,
            reinforcement_episode_ids: Vec::new(),
            suppression_episode_ids: Vec::new(),
            patch_version: 1,
            fitness: Some(ema0),
            declared_reads: Vec::new(),
            retired: false,
            label: String::new(),
            trace_id: None,
            pattern_observation_count: 1,
            prompt_eligible: false,
        };
        procedural.recompute_success_rate();
        Self::base(
            MemoryCategory::Procedural,
            default_importance_score(),
            String::new(),
            AinlNodeType::Procedural { procedural },
        )
    }

    /// Create a new persona trait node
    pub fn new_persona(trait_name: String, strength: f32, learned_from: Vec<Uuid>) -> Self {
        let persona = PersonaNode {
            trait_name,
            strength,
            learned_from,
            layer: PersonaLayer::default(),
            source: PersonaSource::default(),
            strength_floor: default_strength_floor(),
            locked: false,
            relevance_score: 0.0,
            provenance_episode_ids: Vec::new(),
            evolution_log: Vec::new(),
            axis_scores: HashMap::new(),
            evolution_cycle: 0,
            last_evolved: String::new(),
            agent_id: String::new(),
            dominant_axes: Vec::new(),
        };
        Self::base(
            MemoryCategory::Persona,
            default_importance_score(),
            String::new(),
            AinlNodeType::Persona { persona },
        )
    }

    /// Create a trajectory node linked to an episode graph row (`episode_id` = episode node's `id`).
    pub fn new_trajectory(trajectory: TrajectoryNode, agent_id: impl Into<String>) -> Self {
        Self::base(
            MemoryCategory::Trajectory,
            default_importance_score(),
            agent_id.into(),
            AinlNodeType::Trajectory { trajectory },
        )
    }

    /// Failure node from the agent loop loop-guard (`verdict_label`: `block` | `circuit_break`).
    pub fn new_loop_guard_failure(
        verdict_label: &str,
        tool_name: Option<&str>,
        message: impl Into<String>,
        session_id: Option<&str>,
    ) -> Self {
        let recorded_at = chrono::Utc::now().timestamp();
        let source = format!("loop_guard:{verdict_label}");
        let failure = FailureNode {
            recorded_at,
            source,
            tool_name: tool_name.map(str::to_string),
            message: message.into(),
            session_id: session_id.map(str::to_string),
        };
        Self::base(
            MemoryCategory::Failure,
            default_importance_score(),
            String::new(),
            AinlNodeType::Failure { failure },
        )
    }

    /// Failure node from a host tool execution error (OpenFang `tool_runner`, MCP dispatch, etc.).
    ///
    /// `source` is fixed to `tool_runner:error` for FTS recall alongside loop-guard failures.
    pub fn new_tool_execution_failure(
        tool_name: &str,
        message: impl Into<String>,
        session_id: Option<&str>,
    ) -> Self {
        let recorded_at = chrono::Utc::now().timestamp();
        let source = "tool_runner:error".to_string();
        let failure = FailureNode {
            recorded_at,
            source,
            tool_name: Some(tool_name.to_string()),
            message: message.into(),
            session_id: session_id.map(str::to_string),
        };
        Self::base(
            MemoryCategory::Failure,
            default_importance_score(),
            String::new(),
            AinlNodeType::Failure { failure },
        )
    }

    /// Failure node for tool calls rejected in the agent loop **before** `execute_tool` (hooks,
    /// required-parameter validation, etc.). `kind` becomes `agent_loop:{kind}` in [`FailureNode::source`]
    /// (e.g. `hook_blocked`, `param_validation`) for FTS recall alongside `tool_runner:error`.
    pub fn new_agent_loop_precheck_failure(
        kind: &str,
        tool_name: &str,
        message: impl Into<String>,
        session_id: Option<&str>,
    ) -> Self {
        let recorded_at = chrono::Utc::now().timestamp();
        let source = format!("agent_loop:{kind}");
        let failure = FailureNode {
            recorded_at,
            source,
            tool_name: Some(tool_name.to_string()),
            message: message.into(),
            session_id: session_id.map(str::to_string),
        };
        Self::base(
            MemoryCategory::Failure,
            default_importance_score(),
            String::new(),
            AinlNodeType::Failure { failure },
        )
    }

    /// Failure node when `ainl-runtime` rejects a turn because the loaded graph fails validation
    /// (dangling edges, etc.). Fixed `source` for FTS recall with other failure origins.
    pub fn new_ainl_runtime_graph_validation_failure(
        message: impl Into<String>,
        session_id: Option<&str>,
    ) -> Self {
        let recorded_at = chrono::Utc::now().timestamp();
        let source = "ainl_runtime:graph_validation".to_string();
        let failure = FailureNode {
            recorded_at,
            source,
            tool_name: None,
            message: message.into(),
            session_id: session_id.map(str::to_string),
        };
        Self::base(
            MemoryCategory::Failure,
            default_importance_score(),
            String::new(),
            AinlNodeType::Failure { failure },
        )
    }

    pub fn episodic(&self) -> Option<&EpisodicNode> {
        match &self.node_type {
            AinlNodeType::Episode { episodic } => Some(episodic),
            _ => None,
        }
    }

    pub fn semantic(&self) -> Option<&SemanticNode> {
        match &self.node_type {
            AinlNodeType::Semantic { semantic } => Some(semantic),
            _ => None,
        }
    }

    pub fn procedural(&self) -> Option<&ProceduralNode> {
        match &self.node_type {
            AinlNodeType::Procedural { procedural } => Some(procedural),
            _ => None,
        }
    }

    pub fn persona(&self) -> Option<&PersonaNode> {
        match &self.node_type {
            AinlNodeType::Persona { persona } => Some(persona),
            _ => None,
        }
    }

    pub fn trajectory(&self) -> Option<&TrajectoryNode> {
        match &self.node_type {
            AinlNodeType::Trajectory { trajectory } => Some(trajectory),
            _ => None,
        }
    }

    pub fn failure(&self) -> Option<&FailureNode> {
        match &self.node_type {
            AinlNodeType::Failure { failure } => Some(failure),
            _ => None,
        }
    }

    /// Add an edge to another node
    pub fn add_edge(&mut self, target_id: Uuid, label: impl Into<String>) {
        self.edges.push(AinlEdge {
            target_id,
            label: label.into(),
        });
    }
}

#[cfg(test)]
mod trajectory_tests {
    use super::*;
    use uuid::Uuid;

    #[test]
    fn trajectory_node_serde_roundtrip() {
        let traj = TrajectoryNode {
            episode_id: Uuid::nil(),
            recorded_at: 1700000000,
            session_id: "sess".into(),
            project_id: Some("proj".into()),
            ainl_source_hash: Some("abc".into()),
            outcome: TrajectoryOutcome::Success,
            steps: vec![TrajectoryStep {
                step_id: "1".into(),
                timestamp_ms: 1,
                adapter: "http".into(),
                operation: "GET".into(),
                inputs_preview: None,
                outputs_preview: None,
                duration_ms: 2,
                success: true,
                error: None,
                vitals: None,
                freshness_at_step: None,
                frame_vars: None,
                tool_telemetry: None,
            }],
            duration_ms: 10,
            frame_vars: None,
            fitness_delta: None,
        };
        let node = AinlMemoryNode {
            id: Uuid::nil(),
            memory_category: MemoryCategory::Trajectory,
            importance_score: 0.5,
            agent_id: "agent".into(),
            project_id: None,
            node_type: AinlNodeType::Trajectory { trajectory: traj },
            edges: Vec::new(),
        };
        let json = serde_json::to_string(&node).expect("serialize");
        let back: AinlMemoryNode = serde_json::from_str(&json).expect("deserialize");
        assert!(matches!(
            back.node_type,
            AinlNodeType::Trajectory { .. }
        ));
        assert_eq!(
            back.trajectory().map(|t| t.episode_id),
            Some(Uuid::nil())
        );
    }
}
