use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn};

pub mod resources;
pub use resources::{FileSystemResolver, ResourceEntry, ResourceResolver};

use crate::tools::sanitize::sanitize_external_content;
use crate::traits::{
    BehaviorPattern, Episode, ErrorSolution, Expertise, Fact, Goal, ModelProvider, Person,
    PersonFact, Procedure, UserProfile,
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
    pub name: String,
    pub description: String,
    pub triggers: Vec<String>,
    pub body: String,
    /// Where this skill came from: "filesystem", "url", "inline", "auto"
    #[serde(default)]
    pub source: Option<String>,
    /// URL this skill was fetched from (if source is "url")
    #[serde(default)]
    pub source_url: Option<String>,
    /// Database ID for dynamic skills (None for filesystem skills)
    #[serde(default)]
    pub id: Option<i64>,
    /// Whether the skill is enabled (defaults to true)
    #[serde(default = "default_enabled")]
    pub enabled: bool,
    /// Filesystem path for directory-based skills (None for single-file/dynamic skills)
    #[serde(default)]
    pub dir_path: Option<PathBuf>,
    /// Available resources (metadata only — file names, not content)
    #[serde(default)]
    pub resources: Vec<ResourceEntry>,
}

fn default_enabled() -> bool {
    true
}

/// Thread-safe, shared skill registry. Used by Agent, UseSkillTool, and ManageSkillsTool.
#[derive(Clone)]
pub struct SharedSkillRegistry {
    inner: Arc<RwLock<Vec<Skill>>>,
}

impl SharedSkillRegistry {
    pub fn new(skills: Vec<Skill>) -> Self {
        Self {
            inner: Arc::new(RwLock::new(skills)),
        }
    }

    /// Add a skill to the registry.
    pub async fn add(&self, skill: Skill) {
        self.inner.write().await.push(skill);
    }

    /// Remove a skill by name. Returns true if found and removed.
    pub async fn remove(&self, name: &str) -> bool {
        let mut skills = self.inner.write().await;
        let len_before = skills.len();
        skills.retain(|s| s.name != name);
        skills.len() < len_before
    }

    /// Get a snapshot of all enabled skills.
    pub async fn snapshot(&self) -> Vec<Skill> {
        self.inner
            .read()
            .await
            .iter()
            .filter(|s| s.enabled)
            .cloned()
            .collect()
    }

    /// Get a snapshot of all skills (including disabled).
    pub async fn snapshot_all(&self) -> Vec<Skill> {
        self.inner.read().await.clone()
    }

    /// Find a skill by name (among enabled skills).
    pub async fn find(&self, name: &str) -> Option<Skill> {
        self.inner
            .read()
            .await
            .iter()
            .find(|s| s.name == name && s.enabled)
            .cloned()
    }

    /// Set the enabled flag for a skill by name. Returns true if found.
    pub async fn set_enabled(&self, name: &str, enabled: bool) -> bool {
        let mut skills = self.inner.write().await;
        if let Some(skill) = skills.iter_mut().find(|s| s.name == name) {
            skill.enabled = enabled;
            true
        } else {
            false
        }
    }
}

impl Skill {
    /// Parse a skill from markdown content with `---` frontmatter.
    pub fn parse(content: &str) -> Option<Skill> {
        let trimmed = content.trim();
        if !trimmed.starts_with("---") {
            return None;
        }

        // Split on the closing `---`
        let after_opening = &trimmed[3..];
        let end_idx = after_opening.find("---")?;
        let frontmatter = &after_opening[..end_idx];
        let body = after_opening[end_idx + 3..].trim().to_string();

        let mut name = None;
        let mut description = None;
        let mut triggers = Vec::new();

        for line in frontmatter.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            if let Some((key, value)) = line.split_once(':') {
                let key = key.trim();
                let value = value.trim();
                match key {
                    "name" => name = Some(value.to_string()),
                    "description" => description = Some(value.to_string()),
                    "triggers" => {
                        triggers = value
                            .split(',')
                            .map(|s| s.trim().to_lowercase())
                            .filter(|s| !s.is_empty())
                            .collect();
                    }
                    _ => {}
                }
            }
        }

        // If no explicit triggers, extract keywords from name (Anthropic format compatibility)
        if triggers.is_empty() {
            if let Some(ref n) = name {
                triggers = n
                    .split('-')
                    .map(|s| s.to_lowercase())
                    .filter(|s| s.len() > 2)
                    .collect();
            }
        }

        Some(Skill {
            name: name?,
            description: description.unwrap_or_default(),
            triggers,
            body,
            source: None,
            source_url: None,
            id: None,
            enabled: true,
            dir_path: None,
            resources: vec![],
        })
    }
}

/// Scan a skill directory for resource files in any subdirectory.
fn scan_skill_resources(dir: &Path) -> Vec<ResourceEntry> {
    let mut entries = Vec::new();
    let Ok(subdirs) = std::fs::read_dir(dir) else {
        return entries;
    };

    for subdir_entry in subdirs.flatten() {
        let subdir_path = subdir_entry.path();
        if !subdir_path.is_dir() {
            continue;
        }

        let subdir_name = match subdir_path.file_name().and_then(|n| n.to_str()) {
            Some(n) => n.to_string(),
            None => continue,
        };

        // Infer category from directory name
        let category = match subdir_name.as_str() {
            "scripts" => "script",
            "references" => "reference",
            "assets" => "asset",
            other => other, // custom directories work too
        }
        .to_string();

        if let Ok(files) = std::fs::read_dir(&subdir_path) {
            for file in files.flatten() {
                let path = file.path();
                if path.is_file() {
                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                        entries.push(ResourceEntry {
                            path: format!("{}/{}", subdir_name, name),
                            category: category.clone(),
                        });
                    }
                }
            }
        }
    }

    entries.sort_by(|a, b| a.path.cmp(&b.path));
    entries
}

/// Load a directory-based skill from a directory containing SKILL.md.
fn load_directory_skill(dir: &Path) -> Option<Skill> {
    let skill_md = dir.join("SKILL.md");
    let content = std::fs::read_to_string(&skill_md).ok()?;
    let mut skill = Skill::parse(&content)?;
    skill.dir_path = Some(dir.to_path_buf());
    skill.resources = scan_skill_resources(dir);
    Some(skill)
}

/// Load skills from a directory. Supports both:
/// - Legacy single `.md` files (e.g. `skills/deploy.md`)
/// - Directory-based skills with `SKILL.md` (e.g. `skills/deploy/SKILL.md`)
pub fn load_skills(dir: &Path) -> Vec<Skill> {
    let mut skills = Vec::new();

    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(e) => {
            warn!(path = %dir.display(), error = %e, "Could not read skills directory");
            return skills;
        }
    };

    for entry in entries.flatten() {
        let path = entry.path();

        if path.is_dir() {
            // Directory-based skill: look for SKILL.md
            if let Some(skill) = load_directory_skill(&path) {
                info!(
                    name = %skill.name,
                    triggers = ?skill.triggers,
                    resources = skill.resources.len(),
                    "Loaded directory skill"
                );
                skills.push(skill);
            }
        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
            // Legacy single-file skill
            match std::fs::read_to_string(&path) {
                Ok(content) => {
                    if let Some(skill) = Skill::parse(&content) {
                        info!(name = %skill.name, triggers = ?skill.triggers, "Loaded skill");
                        skills.push(skill);
                    } else {
                        warn!(path = %path.display(), "Failed to parse skill file");
                    }
                }
                Err(e) => {
                    warn!(path = %path.display(), error = %e, "Failed to read skill file");
                }
            }
        }
    }

    skills
}

/// Check if a character is a word boundary (not alphanumeric and not underscore).
fn is_word_boundary(c: char) -> bool {
    !c.is_alphanumeric() && c != '_'
}

/// Check if `word` appears as a whole word in `text` (case-sensitive; caller should
/// lowercase both if needed). Word boundaries are start/end of string or any character
/// that is not alphanumeric/underscore.
fn contains_whole_word(text: &str, word: &str) -> bool {
    if word.is_empty() {
        return false;
    }
    let mut start = 0;
    while let Some(pos) = text[start..].find(word) {
        let abs_pos = start + pos;
        let before_ok = abs_pos == 0
            || text[..abs_pos]
                .chars()
                .next_back()
                .is_none_or(is_word_boundary);
        let after_pos = abs_pos + word.len();
        let after_ok = after_pos >= text.len()
            || text[after_pos..]
                .chars()
                .next()
                .is_none_or(is_word_boundary);
        if before_ok && after_ok {
            return true;
        }
        // Advance past this occurrence
        start = abs_pos + 1;
        if start >= text.len() {
            break;
        }
    }
    false
}

/// Match skills whose trigger keywords appear as whole words in the user message (case-insensitive).
pub fn match_skills<'a>(skills: &'a [Skill], user_message: &str) -> Vec<&'a Skill> {
    let lower = user_message.to_lowercase();
    skills
        .iter()
        .filter(|skill| {
            skill
                .triggers
                .iter()
                .any(|trigger| contains_whole_word(&lower, trigger))
        })
        .collect()
}

/// Ask a fast LLM to confirm which candidate skills are truly relevant to the user message.
/// Returns only the confirmed subset. On any error, returns the full candidate list (fail-open).
pub async fn confirm_skills<'a>(
    provider: &dyn ModelProvider,
    fast_model: &str,
    candidates: Vec<&'a Skill>,
    user_message: &str,
) -> anyhow::Result<Vec<&'a Skill>> {
    if candidates.is_empty() {
        return Ok(candidates);
    }

    let skills_list: String = candidates
        .iter()
        .map(|s| format!("- {}: {}", s.name, s.description))
        .collect::<Vec<_>>()
        .join("\n");

    let prompt = format!(
        "Given this user message, which of these skills (if any) are relevant?\n\
         Return ONLY a JSON array of skill names, or [] if none.\n\n\
         Message: \"{}\"\n\n\
         Skills:\n{}",
        user_message, skills_list
    );

    let messages = vec![
        json!({"role": "system", "content": "You are a skill classifier. Respond with only a JSON array of skill names."}),
        json!({"role": "user", "content": prompt}),
    ];

    let response = provider.chat(fast_model, &messages, &[]).await?;
    let text = response
        .content
        .ok_or_else(|| anyhow::anyhow!("Empty response from skill confirmation LLM"))?;

    // Parse: strip markdown fences, find [...], deserialize
    let trimmed = text.trim();
    let json_str = if let Some(start) = trimmed.find('[') {
        if let Some(end) = trimmed.rfind(']') {
            &trimmed[start..=end]
        } else {
            return Ok(candidates); // malformed, fail-open
        }
    } else {
        return Ok(candidates); // no array found, fail-open
    };

    let names: Vec<String> = match serde_json::from_str(json_str) {
        Ok(n) => n,
        Err(_) => return Ok(candidates), // parse error, fail-open
    };

    let confirmed: Vec<&'a Skill> = candidates
        .into_iter()
        .filter(|s| names.iter().any(|n| n == &s.name))
        .collect();

    Ok(confirmed)
}

/// Build the complete system prompt from base prompt, all skills (for listing),
/// active skills (full body injection), and known facts.
///
/// `max_facts` caps the number of facts injected into the prompt. Facts are
/// assumed to arrive ordered by most-recently-updated first (from `get_facts()`).
#[allow(dead_code)] // Kept for backwards compatibility, use build_system_prompt_with_memory instead
pub fn build_system_prompt(
    base: &str,
    skills: &[Skill],
    active: &[&Skill],
    facts: &[Fact],
    max_facts: usize,
) -> String {
    let mut prompt = base.to_string();

    // Available skills listing
    if !skills.is_empty() {
        prompt.push_str("\n\n## Available Skills\n");
        for skill in skills {
            prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
        }
    }

    // Active skill bodies (sanitized to prevent prompt injection from external skill sources)
    for skill in active {
        let sanitized_body = sanitize_external_content(&skill.body);
        prompt.push_str(&format!(
            "\n\n## Active Skill: {}\n{}",
            skill.name, sanitized_body
        ));
    }

    // Known facts (capped to max_facts, already ordered by updated_at DESC)
    let capped_facts = if facts.len() > max_facts {
        &facts[..max_facts]
    } else {
        facts
    };
    if !capped_facts.is_empty() {
        prompt.push_str("\n\n## Known Facts\n");
        for f in capped_facts {
            prompt.push_str(&format!("- [{}] {}: {}\n", f.category, f.key, f.value));
        }
    }

    prompt
}

/// Extended context for memory-rich system prompts.
#[derive(Default)]
pub struct MemoryContext<'a> {
    pub facts: &'a [Fact],
    pub episodes: &'a [Episode],
    pub goals: &'a [Goal],
    pub patterns: &'a [BehaviorPattern],
    pub procedures: &'a [Procedure],
    pub error_solutions: &'a [ErrorSolution],
    pub expertise: &'a [Expertise],
    pub profile: Option<&'a UserProfile>,
    /// Trusted command patterns (pattern string, approval count)
    pub trusted_command_patterns: &'a [(String, i32)],
    /// Cross-channel hints: relevant facts from other channels (category+key only, values under confidentiality)
    pub cross_channel_hints: &'a [Fact],
    /// All tracked people (injected only in owner DMs)
    pub people: &'a [Person],
    /// The current speaker (resolved from sender_id), if any
    pub current_person: Option<&'a Person>,
    /// Facts about the current speaker
    pub current_person_facts: &'a [PersonFact],
}

/// Build the complete system prompt with all memory components.
///
/// This extended version injects episodic memory, goals, procedures, expertise,
/// and behavior patterns in addition to facts and skills.
/// Replace known user IDs (e.g., `U04S8KSS932`) with display names in text.
/// Matches both bare IDs and `<@USERID>` Slack mention format.
fn resolve_user_ids(text: &str, user_id_map: &HashMap<String, String>) -> String {
    if user_id_map.is_empty() {
        return text.to_string();
    }
    let mut result = text.to_string();
    for (uid, name) in user_id_map {
        // Replace <@USERID> format (Slack mentions)
        let mention = format!("<@{}>", uid);
        if result.contains(&mention) {
            result = result.replace(&mention, &format!("@{}", name));
        }
        // Replace bare user IDs (e.g., in stored facts)
        if result.contains(uid.as_str()) {
            result = result.replace(uid.as_str(), name);
        }
    }
    result
}

pub fn build_system_prompt_with_memory(
    base: &str,
    skills: &[Skill],
    active: &[&Skill],
    memory: &MemoryContext,
    max_facts: usize,
    suggestions: Option<&[crate::memory::proactive::Suggestion]>,
    user_id_map: &HashMap<String, String>,
) -> String {
    let mut prompt = base.to_string();

    // 1. Communication Style (from user profile)
    if let Some(profile) = memory.profile {
        prompt.push_str("\n\n## Communication Preferences\n");
        prompt.push_str(&format!(
            "- Verbosity: {}\n- Tone: {}\n- Explanation depth: {}\n",
            profile.verbosity_preference, profile.tone_preference, profile.explanation_depth
        ));
        if profile.likes_suggestions {
            prompt.push_str("- User appreciates proactive suggestions\n");
        }
        if !profile.prefers_explanations {
            prompt.push_str("- Keep explanations brief — user prefers direct answers\n");
        }
        if profile.asks_before_acting {
            prompt.push_str(
                "- User prefers to be asked before significant actions are taken. \
                For multi-step tasks, briefly state your plan and confirm before executing.\n",
            );
        }
    }

    // 2. Expertise Levels
    if !memory.expertise.is_empty() {
        prompt.push_str("\n\n## Your Expertise Levels\n");
        prompt.push_str("| Domain | Level | Confidence |\n|--------|-------|------------|\n");
        for exp in memory.expertise.iter().take(10) {
            prompt.push_str(&format!(
                "| {} | {} | {:.0}% |\n",
                exp.domain,
                exp.current_level,
                exp.confidence_score * 100.0
            ));
        }
    }

    // 3. Known Procedures (high success rate)
    let good_procedures: Vec<&Procedure> = memory
        .procedures
        .iter()
        .filter(|p| p.success_count > p.failure_count && p.success_count >= 2)
        .take(5)
        .collect();
    if !good_procedures.is_empty() {
        prompt.push_str("\n\n## Known Procedures\n");
        prompt.push_str("I've successfully used these approaches before:\n");
        for proc in good_procedures {
            let success_rate = proc.success_count as f32
                / (proc.success_count + proc.failure_count) as f32
                * 100.0;
            prompt.push_str(&format!(
                "- **{}** (trigger: '{}', {:.0}% success): {}\n",
                proc.name,
                truncate(&proc.trigger_pattern, 30),
                success_rate,
                proc.steps.first().map(|s| s.as_str()).unwrap_or("...")
            ));
        }
    }

    // 4. Known Error Fixes (high success rate)
    let good_solutions: Vec<&ErrorSolution> = memory
        .error_solutions
        .iter()
        .filter(|s| s.success_count > s.failure_count && s.success_count >= 2)
        .take(5)
        .collect();
    if !good_solutions.is_empty() {
        prompt.push_str("\n\n## Known Error Solutions\n");
        prompt.push_str("I've resolved these types of errors before:\n");
        for sol in good_solutions {
            prompt.push_str(&format!(
                "- **{}**: {} ({}x successful)\n",
                truncate(&sol.error_pattern, 50),
                sol.solution_summary,
                sol.success_count
            ));
        }
    }

    // 5. Active Goals
    let active_goals: Vec<&Goal> = memory
        .goals
        .iter()
        .filter(|g| g.status == "active")
        .take(5)
        .collect();
    if !active_goals.is_empty() {
        prompt.push_str("\n\n## Active Goals\n");
        prompt.push_str("The user is working toward:\n");
        for goal in active_goals {
            let priority_marker = match goal.priority.as_str() {
                "high" => "🔴",
                "medium" => "🟡",
                _ => "⚪",
            };
            prompt.push_str(&format!("- {} {}\n", priority_marker, goal.description));
        }
    }

    // 6. Known Facts (with history indication)
    let capped_facts = if memory.facts.len() > max_facts {
        &memory.facts[..max_facts]
    } else {
        memory.facts
    };
    if !capped_facts.is_empty() {
        prompt.push_str("\n\n## Known Facts\n");
        for f in capped_facts {
            let history_marker = if f.recall_count > 3 {
                " (frequently used)"
            } else {
                ""
            };
            let resolved_key = resolve_user_ids(&f.key, user_id_map);
            let resolved_value = resolve_user_ids(&f.value, user_id_map);
            // Sanitize fact values to prevent prompt injection via stored data
            let sanitized_value = sanitize_external_content(&resolved_value);
            prompt.push_str(&format!(
                "- [{}] {}: {}{}\n",
                f.category, resolved_key, sanitized_value, history_marker
            ));
        }
    }

    // 6b. Cross-Channel Hints (facts from other channels — confidential until approved)
    if !memory.cross_channel_hints.is_empty() {
        prompt.push_str("\n\n## Cross-Channel Context — CONFIDENTIAL until owner approves\n");
        prompt.push_str("You have relevant info from other conversations. You may mention that you have info,\n\
                         but DO NOT reveal the values below unless the owner explicitly says to share.\n\
                         If the owner approves, use `share_memory` tool to make the info permanently shareable here.\n");
        for f in memory.cross_channel_hints.iter().take(5) {
            let resolved_key = resolve_user_ids(&f.key, user_id_map);
            let resolved_value = resolve_user_ids(&f.value, user_id_map);
            // Sanitize cross-channel hints to prevent injection
            let sanitized_value = sanitize_external_content(&resolved_value);
            prompt.push_str(&format!(
                "- [{}] {}: \"{}\" (from another channel)\n",
                f.category, resolved_key, sanitized_value
            ));
        }
    }

    // 7. Relevant Past Experiences
    if !memory.episodes.is_empty() {
        prompt.push_str("\n\n## Relevant Past Sessions\n");
        for ep in memory.episodes.iter().take(3) {
            let tone = ep.emotional_tone.as_deref().unwrap_or("neutral");
            let outcome = ep.outcome.as_deref().unwrap_or("unknown");
            prompt.push_str(&format!(
                "- {} (tone: {}, outcome: {})\n",
                truncate(&ep.summary, 100),
                tone,
                outcome
            ));
        }
    }

    // 8. Behavior Patterns (high confidence)
    let confident_patterns: Vec<&BehaviorPattern> = memory
        .patterns
        .iter()
        .filter(|p| p.confidence >= 0.7)
        .take(3)
        .collect();
    if !confident_patterns.is_empty() {
        prompt.push_str("\n\n## Observed Patterns\n");
        for pattern in confident_patterns {
            prompt.push_str(&format!("- {}\n", pattern.description));
        }
    }

    // 9. Trusted Command Patterns
    if !memory.trusted_command_patterns.is_empty() {
        prompt.push_str("\n\n## Trusted Command Patterns\n");
        prompt.push_str("These command patterns have been approved multiple times and will have reduced approval friction:\n");
        for (pattern, count) in memory.trusted_command_patterns.iter().take(10) {
            prompt.push_str(&format!("- `{}` (approved {}x)\n", pattern, count));
        }
        prompt.push_str(
            "\nWhen suggesting terminal commands, prefer simpler commands over complex pipelines. ",
        );
        prompt.push_str("If a pipeline is necessary, explain why each part is needed.\n");
    }

    // 10. Contextual Suggestions (if user likes them)
    if let Some(suggestions) = suggestions {
        if !suggestions.is_empty() {
            prompt.push_str("\n\n## Contextual Suggestions\n");
            prompt.push_str("Consider these if relevant (do not force):\n");
            for s in suggestions.iter().take(3) {
                prompt.push_str(&format!("- {}\n", s.text));
            }
        }
    }

    // 11. People Context (only when people data exists)
    if !memory.people.is_empty() {
        prompt.push_str("\n\n## People You Know\n");
        for p in memory.people.iter().take(20) {
            let rel = p.relationship.as_deref().unwrap_or("contact");
            let style = p
                .communication_style
                .as_deref()
                .map(|s| format!(", style: {}", s))
                .unwrap_or_default();
            let lang = p
                .language_preference
                .as_deref()
                .map(|l| format!(", language: {}", l))
                .unwrap_or_default();
            prompt.push_str(&format!("- **{}** ({}){}{}\n", p.name, rel, style, lang));
        }
    }

    // 12. Current Speaker Context (when talking to a known person who is not the owner)
    if let Some(person) = memory.current_person {
        prompt.push_str(&format!(
            "\n\n## Current Speaker Context\nYou are talking to {} ",
            person.name
        ));
        if let Some(ref rel) = person.relationship {
            prompt.push_str(&format!("(the owner's {}). ", rel));
        } else {
            prompt.push_str("(a known contact). ");
        }
        if let Some(ref style) = person.communication_style {
            prompt.push_str(&format!("Communication style: {}. ", style));
        }
        if let Some(ref lang) = person.language_preference {
            prompt.push_str(&format!("Language preference: {}. ", lang));
        }
        prompt.push('\n');

        // Add relevant facts about this person
        if !memory.current_person_facts.is_empty() {
            prompt.push_str("Known facts about them:\n");
            for f in memory.current_person_facts.iter().take(10) {
                prompt.push_str(&format!("- [{}] {}: {}\n", f.category, f.key, f.value));
            }
        }

        // First interaction notice
        if person.interaction_count == 0 {
            prompt.push_str(&format!(
                "\nThis is your first interaction with {}. Naturally mention early in the conversation: \
                 \"I learn from our conversations and adapt my communication style over time. \
                 If you have any preferences, just let me know!\"\n",
                person.name
            ));
        }
    }

    // 13. People Privacy Rules (when people data exists)
    if !memory.people.is_empty() || memory.current_person.is_some() {
        prompt.push_str("\n\n## People Privacy Rules\n\
            - You ARE transparent about: adapting communication style, learning from conversations\n\
            - You are NOT transparent about: the owner's people graph, relationship insights, stored facts about people, or proactive coaching\n\
            - Only share detailed information about people with the OWNER in private DMs\n\
            - If someone asks \"what do you know about me?\", you may share what THEY have told you directly, but NOT facts the owner stored or things learned from other contexts\n\
            - Never say \"the owner told me about you\" or share facts from the owner's private notes\n\
            - In group chats, do not volunteer personal facts about any individual\n\
            - When proactively reminding the owner about dates/events, do so naturally (\"By the way, someone's birthday is coming up next week!\")\n");
    }

    // 14. Available Skills
    if !skills.is_empty() {
        prompt.push_str("\n\n## Available Skills\n");
        for skill in skills {
            prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
        }
    }

    // Active skill bodies (sanitized to prevent prompt injection from external skill sources)
    for skill in active {
        let sanitized_body = sanitize_external_content(&skill.body);
        prompt.push_str(&format!(
            "\n\n## Active Skill: {}\n{}",
            skill.name, sanitized_body
        ));
        if !skill.resources.is_empty() {
            prompt.push_str(
                "\n\n**Bundled resources** (use `skill_resources` tool to load on demand):",
            );
            for entry in &skill.resources {
                prompt.push_str(&format!("\n- [{}] `{}`", entry.category, entry.path));
            }
        }
    }

    prompt
}

fn truncate(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        s.to_string()
    } else {
        format!("{}...", &s[..max_len])
    }
}

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

    // --- contains_whole_word tests ---

    #[test]
    fn whole_word_at_start() {
        assert!(contains_whole_word("browse the web", "browse"));
    }

    #[test]
    fn whole_word_at_end() {
        assert!(contains_whole_word("open the browser", "browser"));
    }

    #[test]
    fn whole_word_in_middle() {
        assert!(contains_whole_word("please browse now", "browse"));
    }

    #[test]
    fn whole_word_with_punctuation() {
        assert!(contains_whole_word("browse, please", "browse"));
        assert!(contains_whole_word("use browse.", "browse"));
        assert!(contains_whole_word("(browse)", "browse"));
    }

    #[test]
    fn whole_word_hyphenated() {
        assert!(contains_whole_word("web-browse tool", "browse"));
    }

    #[test]
    fn partial_match_rejected() {
        assert!(!contains_whole_word("browseable interface", "browse"));
        assert!(!contains_whole_word("prebrowse step", "browse"));
    }

    #[test]
    fn substring_in_longer_word_rejected() {
        assert!(!contains_whole_word("remembering things", "member"));
    }

    #[test]
    fn exact_match_only_word() {
        assert!(contains_whole_word("browse", "browse"));
    }

    #[test]
    fn empty_word_returns_false() {
        assert!(!contains_whole_word("anything", ""));
    }

    #[test]
    fn empty_text_returns_false() {
        assert!(!contains_whole_word("", "word"));
    }

    #[test]
    fn word_with_underscore_boundary() {
        // underscore is NOT a word boundary, so "disk" inside "disk_check" is NOT a whole word
        assert!(!contains_whole_word("run disk_check", "disk"));
        // but "disk_check" as a whole word IS matched
        assert!(contains_whole_word("run disk_check now", "disk_check"));
    }

    // --- match_skills tests ---

    fn make_skill(name: &str, triggers: &[&str]) -> Skill {
        Skill {
            name: name.to_string(),
            description: format!("{} skill", name),
            triggers: triggers.iter().map(|t| t.to_lowercase()).collect(),
            body: String::new(),
            source: None,
            source_url: None,
            id: None,
            enabled: true,
            dir_path: None,
            resources: vec![],
        }
    }

    #[test]
    fn match_skills_whole_word() {
        let skills = vec![
            make_skill("web-browsing", &["browse", "website"]),
            make_skill("system-admin", &["disk", "memory", "cpu"]),
        ];
        // "browse" as whole word matches
        let matched = match_skills(&skills, "browse google.com");
        assert_eq!(matched.len(), 1);
        assert_eq!(matched[0].name, "web-browsing");
    }

    #[test]
    fn match_skills_rejects_partial() {
        let skills = vec![make_skill("web-browsing", &["browse", "website"])];
        // "browseable" should NOT match "browse"
        let matched = match_skills(&skills, "this is a browseable page");
        assert!(matched.is_empty());
    }

    #[test]
    fn match_skills_case_insensitive() {
        let skills = vec![make_skill("web-browsing", &["browse"])];
        let matched = match_skills(&skills, "Please BROWSE the site");
        assert_eq!(matched.len(), 1);
    }

    // --- build_system_prompt max_facts tests ---

    fn make_fact(category: &str, key: &str, value: &str) -> Fact {
        Fact {
            id: 0,
            category: category.to_string(),
            key: key.to_string(),
            value: value.to_string(),
            source: "test".to_string(),
            created_at: chrono::Utc::now(),
            updated_at: chrono::Utc::now(),
            superseded_at: None,
            recall_count: 0,
            last_recalled_at: None,
            channel_id: None,
            privacy: crate::types::FactPrivacy::Global,
        }
    }

    #[test]
    fn build_prompt_caps_facts() {
        let facts: Vec<Fact> = (0..10)
            .map(|i| make_fact("user", &format!("key{}", i), &format!("val{}", i)))
            .collect();
        let prompt = build_system_prompt("base", &[], &[], &facts, 3);
        // Should contain exactly 3 facts
        assert!(prompt.contains("key0"));
        assert!(prompt.contains("key2"));
        assert!(!prompt.contains("key3"));
    }

    #[test]
    fn build_prompt_no_cap_when_under_limit() {
        let facts = vec![make_fact("user", "name", "Alice")];
        let prompt = build_system_prompt("base", &[], &[], &facts, 100);
        assert!(prompt.contains("Alice"));
    }

    #[test]
    fn build_prompt_zero_max_facts() {
        let facts = vec![make_fact("user", "name", "Alice")];
        let prompt = build_system_prompt("base", &[], &[], &facts, 0);
        assert!(!prompt.contains("Known Facts"));
    }

    // --- directory skill tests ---

    #[test]
    fn test_load_directory_skill() {
        let dir = tempfile::TempDir::new().unwrap();
        let skill_dir = dir.path().join("test-skill");
        std::fs::create_dir(&skill_dir).unwrap();
        std::fs::create_dir(skill_dir.join("scripts")).unwrap();
        std::fs::create_dir(skill_dir.join("references")).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: test-skill\ndescription: A test\ntriggers: test\n---\nDo the thing.",
        )
        .unwrap();
        std::fs::write(skill_dir.join("scripts/hello.sh"), "#!/bin/bash\necho hi").unwrap();
        std::fs::write(
            skill_dir.join("references/guide.md"),
            "# Guide\nUse snake_case.",
        )
        .unwrap();

        let skill = load_directory_skill(&skill_dir).unwrap();
        assert_eq!(skill.name, "test-skill");
        assert_eq!(skill.resources.len(), 2);
        assert!(skill.dir_path.is_some());
        assert!(skill
            .resources
            .iter()
            .any(|r| r.path == "references/guide.md"));
        assert!(skill.resources.iter().any(|r| r.path == "scripts/hello.sh"));
    }

    #[test]
    fn test_load_skills_mixed() {
        let dir = tempfile::TempDir::new().unwrap();

        // Legacy single-file skill
        std::fs::write(
            dir.path().join("legacy.md"),
            "---\nname: legacy\ndescription: Legacy skill\ntriggers: old\n---\nLegacy body.",
        )
        .unwrap();

        // Directory-based skill
        let skill_dir = dir.path().join("new-skill");
        std::fs::create_dir(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: new-skill\ndescription: New skill\ntriggers: new\n---\nNew body.",
        )
        .unwrap();

        let skills = load_skills(dir.path());
        assert_eq!(skills.len(), 2);
        assert!(skills.iter().any(|s| s.name == "legacy"));
        assert!(skills.iter().any(|s| s.name == "new-skill"));
    }

    #[test]
    fn test_scan_resources_custom_dirs() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir(dir.path().join("examples")).unwrap();
        std::fs::create_dir(dir.path().join("data")).unwrap();
        std::fs::write(dir.path().join("examples/demo.py"), "print('hi')").unwrap();
        std::fs::write(dir.path().join("data/config.json"), "{}").unwrap();

        let resources = scan_skill_resources(dir.path());
        assert_eq!(resources.len(), 2);
        // Custom directories use their dirname as category
        let examples_entry = resources
            .iter()
            .find(|r| r.path == "data/config.json")
            .unwrap();
        assert_eq!(examples_entry.category, "data");
        let data_entry = resources
            .iter()
            .find(|r| r.path == "examples/demo.py")
            .unwrap();
        assert_eq!(data_entry.category, "examples");
    }

    #[test]
    fn test_directory_without_skill_md_skipped() {
        let dir = tempfile::TempDir::new().unwrap();
        let random_dir = dir.path().join("random-stuff");
        std::fs::create_dir(&random_dir).unwrap();
        std::fs::write(random_dir.join("readme.txt"), "not a skill").unwrap();

        let skills = load_skills(dir.path());
        assert!(skills.is_empty());
    }

    #[test]
    fn test_parse_anthropic_format() {
        // Anthropic format: name + description, no explicit triggers
        let content =
            "---\nname: code-review\ndescription: Review code for quality\n---\nCheck for bugs.";
        let skill = Skill::parse(content).unwrap();
        assert_eq!(skill.name, "code-review");
        // Auto-triggers derived from hyphenated name (words > 2 chars)
        assert!(skill.triggers.contains(&"code".to_string()));
        assert!(skill.triggers.contains(&"review".to_string()));
    }
}
