//! Deterministic skill prefilter for two-phase selection.
//!
//! The first phase of skill selection is entirely deterministic -- no LLM involvement,
//! no skill content in context. This prevents circular manipulation where a loaded
//! skill could influence which skills get loaded.
//!
//! Scoring:
//! - Keyword exact match: 10 points (capped at 30 total)
//! - Keyword substring match: 5 points (capped at 30 total)
//! - Tag match: 3 points (capped at 15 total)
//! - Regex pattern match: 20 points (capped at 40 total)

use crate::skills::LoadedSkill;

/// Default maximum context tokens allocated to skills.
pub const MAX_SKILL_CONTEXT_TOKENS: usize = 4000;

/// Maximum keyword score cap per skill to prevent gaming via keyword stuffing.
/// Even if a skill has 20 keywords, it can earn at most this many keyword points.
const MAX_KEYWORD_SCORE: u32 = 30;

/// Maximum tag score cap per skill (parallel to keyword cap).
const MAX_TAG_SCORE: u32 = 15;

/// Maximum regex pattern score cap per skill. Without a cap, 5 patterns at
/// 20 points each could yield 100 points, dominating keyword+tag scores.
const MAX_REGEX_SCORE: u32 = 40;

/// Result of prefiltering with score information.
#[derive(Debug)]
pub struct ScoredSkill<'a> {
    pub skill: &'a LoadedSkill,
    pub score: u32,
}

/// Select candidate skills for a given message using deterministic scoring.
///
/// Returns skills sorted by score (highest first), limited by `max_candidates`
/// and total context budget. No LLM is involved in this selection.
pub fn prefilter_skills<'a>(
    message: &str,
    available_skills: &'a [LoadedSkill],
    max_candidates: usize,
    max_context_tokens: usize,
) -> Vec<&'a LoadedSkill> {
    if available_skills.is_empty() || message.is_empty() {
        return vec![];
    }

    let message_lower = message.to_lowercase();

    let mut scored: Vec<ScoredSkill<'a>> = available_skills
        .iter()
        .filter_map(|skill| {
            let score = score_skill(skill, &message_lower, message);
            if score > 0 {
                Some(ScoredSkill { skill, score })
            } else {
                None
            }
        })
        .collect();

    // Sort by score descending
    scored.sort_by_key(|b| std::cmp::Reverse(b.score));

    // Apply candidate limit and context budget
    let mut result = Vec::new();
    let mut budget_remaining = max_context_tokens;

    for entry in scored {
        if result.len() >= max_candidates {
            break;
        }
        let declared_tokens = entry.skill.manifest.activation.max_context_tokens;
        // Rough token estimate: ~0.25 tokens per byte (~4 bytes per token for English prose)
        let approx_tokens = (entry.skill.prompt_content.len() as f64 * 0.25) as usize;
        let raw_cost = if approx_tokens > declared_tokens * 2 {
            tracing::warn!(
                "Skill '{}' declares max_context_tokens={} but prompt is ~{} tokens; using actual estimate",
                entry.skill.name(),
                declared_tokens,
                approx_tokens,
            );
            approx_tokens
        } else {
            declared_tokens
        };
        // Enforce a minimum token cost so max_context_tokens=0 can't bypass budgeting
        let token_cost = raw_cost.max(1);
        if token_cost <= budget_remaining {
            budget_remaining -= token_cost;
            result.push(entry.skill);
        }
    }

    result
}

/// Score a skill against a user message.
fn score_skill(skill: &LoadedSkill, message_lower: &str, message_original: &str) -> u32 {
    // Exclusion veto: if any exclude_keyword is present in the message, score 0
    if skill
        .lowercased_exclude_keywords
        .iter()
        .any(|excl| message_lower.contains(excl.as_str()))
    {
        return 0;
    }

    let mut score: u32 = 0;

    // Keyword scoring with cap to prevent gaming via keyword stuffing
    let mut keyword_score: u32 = 0;
    for kw_lower in &skill.lowercased_keywords {
        // Exact word match (surrounded by word boundaries)
        if message_lower
            .split_whitespace()
            .any(|word| word.trim_matches(|c: char| !c.is_alphanumeric()) == kw_lower.as_str())
        {
            keyword_score += 10;
        } else if message_lower.contains(kw_lower.as_str()) {
            // Substring match
            keyword_score += 5;
        }
    }
    score += keyword_score.min(MAX_KEYWORD_SCORE);

    // Tag scoring from activation.tags
    let mut tag_score: u32 = 0;
    for tag_lower in &skill.lowercased_tags {
        if message_lower.contains(tag_lower.as_str()) {
            tag_score += 3;
        }
    }
    score += tag_score.min(MAX_TAG_SCORE);

    // Regex pattern scoring using pre-compiled patterns (cached at load time), with cap
    let mut regex_score: u32 = 0;
    for re in &skill.compiled_patterns {
        if re.is_match(message_original) {
            regex_score += 20;
        }
    }
    score += regex_score.min(MAX_REGEX_SCORE);

    score
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills::{ActivationCriteria, LoadedSkill, SkillManifest, SkillSource, SkillTrust};
    use std::path::PathBuf;

    fn make_skill(name: &str, keywords: &[&str], tags: &[&str], patterns: &[&str]) -> LoadedSkill {
        let pattern_strings: Vec<String> = patterns.iter().map(|s| s.to_string()).collect();
        let compiled = LoadedSkill::compile_patterns(&pattern_strings);
        let kw_vec: Vec<String> = keywords.iter().map(|s| s.to_string()).collect();
        let tag_vec: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
        let lowercased_keywords = kw_vec.iter().map(|k| k.to_lowercase()).collect();
        let lowercased_tags = tag_vec.iter().map(|t| t.to_lowercase()).collect();
        LoadedSkill {
            manifest: SkillManifest {
                name: name.to_string(),
                version: "1.0.0".to_string(),
                description: format!("{} skill", name),
                activation: ActivationCriteria {
                    keywords: kw_vec,
                    exclude_keywords: vec![],
                    patterns: pattern_strings,
                    tags: tag_vec,
                    max_context_tokens: 1000,
                },
                metadata: None,
            },
            prompt_content: "Test prompt".to_string(),
            trust: SkillTrust::Trusted,
            source: SkillSource::User(PathBuf::from("/tmp/test")),
            content_hash: "sha256:000".to_string(),
            compiled_patterns: compiled,
            lowercased_keywords,
            lowercased_exclude_keywords: vec![],
            lowercased_tags,
        }
    }

    #[test]
    fn test_empty_message_returns_nothing() {
        let skills = vec![make_skill("test", &["write"], &[], &[])];
        let result = prefilter_skills("", &skills, 3, MAX_SKILL_CONTEXT_TOKENS);
        assert!(result.is_empty());
    }

    #[test]
    fn test_no_matching_skills() {
        let skills = vec![make_skill("cooking", &["recipe", "cook", "bake"], &[], &[])];
        let result = prefilter_skills(
            "Help me write an email",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert!(result.is_empty());
    }

    #[test]
    fn test_keyword_exact_match() {
        let skills = vec![make_skill("writing", &["write", "edit"], &[], &[])];
        let result = prefilter_skills(
            "Please write an email",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].name(), "writing");
    }

    #[test]
    fn test_keyword_substring_match() {
        let skills = vec![make_skill("writing", &["writing"], &[], &[])];
        let result = prefilter_skills(
            "I need help with rewriting this text",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_tag_match() {
        let skills = vec![make_skill("writing", &[], &["prose", "email"], &[])];
        let result = prefilter_skills(
            "Draft an email for me",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_regex_pattern_match() {
        let skills = vec![make_skill(
            "writing",
            &[],
            &[],
            &[r"(?i)\b(write|draft)\b.*\b(email|letter)\b"],
        )];
        let result = prefilter_skills(
            "Please draft an email to my boss",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_scoring_priority() {
        let skills = vec![
            make_skill("cooking", &["cook"], &[], &[]),
            make_skill(
                "writing",
                &["write", "draft"],
                &["email"],
                &[r"(?i)\b(write|draft)\b.*\bemail\b"],
            ),
        ];
        let result = prefilter_skills(
            "Write and draft an email",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].name(), "writing");
    }

    #[test]
    fn test_max_candidates_limit() {
        let skills = vec![
            make_skill("a", &["test"], &[], &[]),
            make_skill("b", &["test"], &[], &[]),
            make_skill("c", &["test"], &[], &[]),
        ];
        let result = prefilter_skills("test", &skills, 2, MAX_SKILL_CONTEXT_TOKENS);
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn test_context_budget_limit() {
        let mut skill = make_skill("big", &["test"], &[], &[]);
        skill.manifest.activation.max_context_tokens = 3000;
        let mut skill2 = make_skill("also_big", &["test"], &[], &[]);
        skill2.manifest.activation.max_context_tokens = 3000;

        let skills = vec![skill, skill2];
        // Budget of 4000 can only fit one 3000-token skill
        let result = prefilter_skills("test", &skills, 5, 4000);
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_invalid_regex_handled_gracefully() {
        let skills = vec![make_skill("bad", &["test"], &[], &["[invalid regex"])];
        let result = prefilter_skills("test", &skills, 3, MAX_SKILL_CONTEXT_TOKENS);
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_keyword_score_capped() {
        let many_keywords: Vec<&str> = vec![
            "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p",
        ];
        let skill = make_skill("spammer", &many_keywords, &[], &[]);
        let skills = vec![skill];
        let result = prefilter_skills(
            "a b c d e f g h i j k l m n o p",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_tag_score_capped() {
        let many_tags: Vec<&str> = vec![
            "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
        ];
        let skill = make_skill("tag-spammer", &[], &many_tags, &[]);
        let skills = vec![skill];
        let result = prefilter_skills(
            "alpha bravo charlie delta echo foxtrot golf hotel",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_regex_score_capped() {
        let skill = make_skill(
            "regex-spammer",
            &[],
            &[],
            &[
                r"(?i)\bwrite\b",
                r"(?i)\bdraft\b",
                r"(?i)\bedit\b",
                r"(?i)\bcompose\b",
                r"(?i)\bauthor\b",
            ],
        );
        let skills = vec![skill];
        let result = prefilter_skills(
            "write draft edit compose author",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn test_zero_context_tokens_still_costs_budget() {
        let mut skill = make_skill("free", &["test"], &[], &[]);
        skill.manifest.activation.max_context_tokens = 0;
        skill.prompt_content = String::new();
        let mut skill2 = make_skill("also_free", &["test"], &[], &[]);
        skill2.manifest.activation.max_context_tokens = 0;
        skill2.prompt_content = String::new();

        let skills = vec![skill, skill2];
        let result = prefilter_skills("test", &skills, 5, 1);
        assert_eq!(result.len(), 1);
    }

    fn make_skill_with_excludes(
        name: &str,
        keywords: &[&str],
        exclude_keywords: &[&str],
        tags: &[&str],
        patterns: &[&str],
    ) -> LoadedSkill {
        let mut skill = make_skill(name, keywords, tags, patterns);
        let excl_vec: Vec<String> = exclude_keywords.iter().map(|s| s.to_string()).collect();
        skill.lowercased_exclude_keywords = excl_vec.iter().map(|k| k.to_lowercase()).collect();
        skill.manifest.activation.exclude_keywords = excl_vec;
        skill
    }

    // --- exclude_keywords tests ---

    #[test]
    fn test_exclude_keyword_vetos_match() {
        // Skill matches on "write" but exclude_keywords: ["route"] — message contains "route"
        // so the skill should score 0 and be excluded.
        let skills = vec![make_skill_with_excludes(
            "writer",
            &["write"],
            &["route"],
            &[],
            &[],
        )];
        let result = prefilter_skills(
            "route this write request to another agent",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert!(
            result.is_empty(),
            "skill with matching exclude_keyword should score 0"
        );
    }

    #[test]
    fn test_exclude_keyword_absent_does_not_block() {
        // Same skill, message does NOT contain the exclude keyword — should activate normally.
        let skills = vec![make_skill_with_excludes(
            "writer",
            &["write"],
            &["route"],
            &[],
            &[],
        )];
        let result = prefilter_skills(
            "help me write an email",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert_eq!(
            result.len(),
            1,
            "skill should activate when no exclude_keyword is present"
        );
    }

    #[test]
    fn test_exclude_keyword_veto_wins_over_positive_match() {
        // Both a keyword match AND an exclude_keyword match are present.
        // The veto must win regardless of how high the positive score is.
        let skills = vec![make_skill_with_excludes(
            "writer",
            &["write", "draft", "compose"],
            &["redirect"],
            &[],
            &[],
        )];
        let result = prefilter_skills(
            "write and draft and compose — but redirect this somewhere else",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert!(
            result.is_empty(),
            "exclude_keyword veto must win even when multiple positive keywords match"
        );
    }

    #[test]
    fn test_exclude_keyword_case_insensitive() {
        // exclude_keywords are pre-lowercased; the veto must fire regardless of case in the message.
        let skills = vec![make_skill_with_excludes(
            "writer",
            &["write"],
            &["Route"],
            &[],
            &[],
        )];
        let result = prefilter_skills(
            "please ROUTE this write request",
            &skills,
            3,
            MAX_SKILL_CONTEXT_TOKENS,
        );
        assert!(
            result.is_empty(),
            "exclude_keyword veto should be case-insensitive"
        );
    }
}
