//! Loop detection for agent operations
//!
//! Detects when the agent is stuck in repetitive patterns and suggests intervention.
//! The unified interactive VT Code runloop applies its own richer turn-local
//! recovery policy; this detector remains the generic safeguard for legacy or
//! non-unified autonomous execution paths.

use crate::config::constants::{defaults, tools};
use crate::tools::tool_intent;
use hashbrown::{HashMap, HashSet};
use std::collections::VecDeque;
use std::time::Instant;

// Separate limits for different operation types to reduce false positives
const MAX_READONLY_TOOL_CALLS: usize = 10; // read_file, grep_file, list_files
const MAX_WRITE_TOOL_CALLS: usize = 3; // write_file, edit_file, apply_patch
const MAX_COMMAND_TOOL_CALLS: usize = 5; // shell, unified_exec
const MAX_OTHER_TOOL_CALLS: usize = 3; // Other tools (default)
const DETECTION_WINDOW: usize = 10;
const HARD_LIMIT_MULTIPLIER: usize = 2; // Hard stop at 2x soft limit
const MAX_SIMILAR_READ_TARGET_CALLS: usize = 5;
const MAX_SIMILAR_READ_TARGET_VARIANTS: usize = 5;
const LEGACY_GREP_FILE: &str = tools::GREP_FILE;
const LEGACY_LIST_FILES: &str = tools::LIST_FILES;
const LEGACY_SEARCH_TOOLS: &str = "search_tools";

#[inline]
fn base_tool_name(tool_name: &str) -> &str {
    tool_name
        .split_once("::")
        .map(|(base, _)| base)
        .unwrap_or(tool_name)
}

#[inline]
fn is_command_tool_name(tool_name: &str) -> bool {
    tool_intent::canonical_unified_exec_tool_name(tool_name).is_some()
}

/// Normalize tool arguments for consistent loop detection.
/// This ensures path variations like ".", "", "./" are treated as the same root path,
/// and read-file parameter aliases (offset_lines, max_lines, chunk_lines, line_start/line_end, etc.)
/// are collapsed to canonical keys so the model can't evade detection by cycling parameter names.
fn normalize_args_for_detection(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
    let base_name = base_tool_name(tool_name);
    if let Some(obj) = args.as_object() {
        let mut normalized = obj.clone();

        // Remove pagination params that shouldn't affect loop detection
        normalized.remove("page");
        normalized.remove("per_page");

        // For list_files: normalize root path variations
        if base_name == LEGACY_LIST_FILES {
            if let Some(path) = normalized.get("path").and_then(|v| v.as_str()) {
                let trimmed = path.trim();
                let only_root_markers = trimmed.trim_matches(|c| c == '.' || c == '/').is_empty();
                if trimmed.is_empty() || only_root_markers {
                    normalized.insert("path".into(), serde_json::json!("__ROOT__"));
                }
            } else {
                normalized.insert("path".into(), serde_json::json!("__ROOT__"));
            }
        }

        // For read-file tools: normalize parameter aliases so cycling through
        // offset_lines/line_start, max_lines/chunk_lines/limit_lines/limit, encoding, action
        // all hash to the same canonical form.
        let is_read_tool = base_name == tools::READ_FILE
            || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
        if is_read_tool {
            // Normalize path aliases to "path"
            for alias in ["file_path", "filepath", "target_path", "file"] {
                if let Some(val) = normalized.remove(alias)
                    && !normalized.contains_key("path")
                {
                    normalized.insert("path".into(), val);
                }
            }

            // Normalize offset aliases to "offset"
            // line_start=N → offset=N, offset_lines=N → offset=N
            for alias in ["offset_lines", "line_start", "offset_bytes"] {
                if let Some(val) = normalized.remove(alias)
                    && !normalized.contains_key("offset")
                {
                    normalized.insert("offset".into(), val);
                }
            }

            // Normalize limit aliases to "limit"
            // max_lines, chunk_lines, limit_lines, page_size_lines, line_end → limit
            if let Some(line_end) = normalized.remove("line_end") {
                // line_start/line_end → offset + limit
                if !normalized.contains_key("limit") {
                    let start = normalized
                        .get("offset")
                        .and_then(|v| v.as_u64())
                        .unwrap_or(1);
                    let end = line_end.as_u64().unwrap_or(start);
                    let limit = end.saturating_sub(start).saturating_add(1);
                    normalized.insert("limit".into(), serde_json::json!(limit));
                }
            }
            for alias in ["max_lines", "chunk_lines", "limit_lines", "page_size_lines"] {
                if let Some(val) = normalized.remove(alias) {
                    normalized.entry(String::from("limit")).or_insert(val);
                }
            }

            // Canonicalize omitted offsets to the first line.
            normalized
                .entry(String::from("offset"))
                .or_insert(serde_json::json!(1));

            // Remove noise params that don't change semantic intent
            normalized.remove("encoding");
            normalized.remove("action");
        }

        serde_json::Value::Object(normalized)
    } else {
        args.clone()
    }
}

#[derive(Debug, Clone)]
pub struct ToolCallRecord {
    pub tool_name: String,
    pub args_hash: u64,
    pub read_target: Option<String>,
    pub timestamp: Instant,
}

#[derive(Debug)]
pub struct LoopDetector {
    recent_calls: VecDeque<ToolCallRecord>,
    tool_counts: HashMap<String, usize>,
    last_warning: Option<Instant>,
    max_identical_call_limit: Option<usize>,
    custom_limits: HashMap<String, usize>,
    /// Cache mapping (tool_name, raw_args) composite hash → normalized_args_hash.
    /// Avoids re-running normalization + re-serialization on repeated identical calls.
    norm_cache: HashMap<u64, u64>,
    /// Tracks consecutive read-only calls without any write/execution progress.
    /// Resets on any mutating tool call.
    readonly_streak: usize,
}

impl LoopDetector {
    pub fn new() -> Self {
        Self::with_max_repeated_calls(defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS)
    }

    pub fn with_max_repeated_calls(limit: usize) -> Self {
        let normalized_limit = if limit > 1 { Some(limit) } else { None };
        Self {
            recent_calls: VecDeque::with_capacity(DETECTION_WINDOW),
            tool_counts: HashMap::new(),
            last_warning: None,
            max_identical_call_limit: normalized_limit,
            custom_limits: HashMap::new(),
            norm_cache: HashMap::with_capacity(16),
            readonly_streak: 0,
        }
    }

    /// Set a custom limit for a specific tool.
    /// This overrides the default category-based limits.
    pub fn set_tool_limit(&mut self, tool_name: &str, limit: usize) {
        self.custom_limits.insert(tool_name.to_string(), limit);
    }

    pub fn record_call(&mut self, tool_name: &str, args: &serde_json::Value) -> Option<String> {
        use std::collections::hash_map::DefaultHasher;
        use std::hash::{Hash, Hasher};

        let mut raw_hasher = DefaultHasher::new();
        tool_name.hash(&mut raw_hasher);
        if let Ok(bytes) = serde_json::to_vec(args) {
            bytes.hash(&mut raw_hasher);
        } else {
            args.to_string().hash(&mut raw_hasher);
        }
        let raw_key = raw_hasher.finish();

        let args_hash = if let Some(&cached) = self.norm_cache.get(&raw_key) {
            cached
        } else {
            let normalized_args = normalize_args_for_detection(tool_name, args);
            let mut hasher = DefaultHasher::new();
            if let Ok(bytes) = serde_json::to_vec(&normalized_args) {
                bytes.hash(&mut hasher);
            } else {
                normalized_args.to_string().hash(&mut hasher);
            }
            let hash = hasher.finish();
            if self.norm_cache.len() >= 16 {
                self.norm_cache.clear();
            }
            self.norm_cache.insert(raw_key, hash);
            hash
        };

        if let Some(limit) = self.max_identical_call_limit
            && Self::should_enforce_identical_limit(tool_name)
        {
            let required_history = limit.saturating_sub(1);
            if required_history > 0 && self.recent_calls.len() >= required_history {
                let identical = self
                    .recent_calls
                    .iter()
                    .rev()
                    .take(required_history)
                    .all(|record| record.tool_name == tool_name && record.args_hash == args_hash);

                if identical {
                    // Escalate to hard limit so callers halt immediately.
                    let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
                    self.tool_counts.insert(tool_name.to_string(), hard_limit);

                    return Some(format!(
                        "HARD STOP: Identical tool call repeated {} times: {} with same arguments. This indicates a loop.",
                        limit, tool_name
                    ));
                }
            }
        }

        let record = ToolCallRecord {
            tool_name: tool_name.to_string(),
            args_hash,
            read_target: read_target_for_tool_call(tool_name, args),
            timestamp: Instant::now(),
        };

        if self.recent_calls.len() >= DETECTION_WINDOW
            && let Some(old) = self.recent_calls.pop_front()
            && let Some(count) = self.tool_counts.get_mut(&old.tool_name)
        {
            *count = count.saturating_sub(1);
        }

        self.recent_calls.push_back(record);
        // Use get_mut + insert to avoid String allocation on every call.
        // entry() would allocate tool_name.to_string() even for existing keys.
        match self.tool_counts.get_mut(tool_name) {
            Some(count) => *count += 1,
            None => {
                self.tool_counts.insert(tool_name.to_string(), 1);
            }
        }

        if let Some(read_target_warning) = self.detect_repetitive_read_target(tool_name) {
            return Some(read_target_warning);
        }

        // --- Navigation Loop Detection (NL2Repo-Bench integration) ---
        let base_name = base_tool_name(tool_name);
        let is_readonly = matches!(
            base_name,
            tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::UNIFIED_SEARCH
        ) || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));

        let is_mutating = matches!(
            base_name,
            tools::WRITE_FILE
                | tools::CREATE_FILE
                | tools::EDIT_FILE
                | tools::UNIFIED_EXEC
                | tools::APPLY_PATCH
        );

        if is_readonly {
            self.readonly_streak += 1;
        } else if is_mutating {
            self.readonly_streak = 0;
        }

        const MAX_NAVIGATION_ONLY_STREAK: usize = 6;
        const NAVIGATION_HARD_STOP_STREAK: usize = 10;
        if self.readonly_streak >= MAX_NAVIGATION_ONLY_STREAK {
            if self.readonly_streak >= NAVIGATION_HARD_STOP_STREAK {
                // Escalate to hard limit after sustained navigation-only usage
                let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
                self.tool_counts.insert(tool_name.to_string(), hard_limit);
                return Some(format!(
                    "HARD STOP: {} consecutive exploration calls without taking action (edit/exec). \
                     Execution halted. Stop reading and provide a concrete answer with the information already collected.",
                    self.readonly_streak
                ));
            }

            let msg = format!(
                "Navigation Loop Detected: {} consecutive exploration calls without taking action (edit/exec).\n\n\
                 **Action Bias Required**: Stop reading and start implementing. If you are stuck, perform a ROOT CAUSE analysis or ask for human steering.",
                self.readonly_streak
            );
            let now = Instant::now();
            let should_warn = self
                .last_warning
                .map(|last| now.duration_since(last).as_secs() > 30)
                .unwrap_or(true);

            if should_warn {
                self.last_warning = Some(now);
                return Some(msg);
            }
        }

        if let Some(pattern_warning) = self.detect_patterns() {
            return Some(pattern_warning);
        }

        self.check_for_loops(tool_name)
    }

    fn check_for_loops(&mut self, tool_name: &str) -> Option<String> {
        let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);

        // Determine tool-specific limits
        let max_calls = self.get_limit_for_tool(tool_name);

        // Hard limit check - immediate halt
        let hard_limit = max_calls * HARD_LIMIT_MULTIPLIER;
        if count >= hard_limit {
            return Some(format!(
                "CRITICAL: Tool '{}' called {} times (hard limit: {}). Execution halted to prevent infinite loop.\n\
                 Agent must reformulate task or request user guidance.",
                tool_name, count, hard_limit
            ));
        }

        // Soft limit - warning with cooldown and alternative suggestions
        if count >= max_calls {
            let now = Instant::now();
            let should_warn = self
                .last_warning
                .map(|last| now.duration_since(last).as_secs() > 30)
                .unwrap_or(true);

            if should_warn {
                self.last_warning = Some(now);
                let alternatives = Self::suggest_alternative_for_tool(tool_name);

                return Some(format!(
                    "Loop detected: '{}' called {} times in last {} operations.\n\n\
                     {}\n\n\
                     Hard limit at {} calls.",
                    tool_name, count, DETECTION_WINDOW, alternatives, hard_limit
                ));
            }
        }

        None
    }

    fn detect_repetitive_read_target(&mut self, tool_name: &str) -> Option<String> {
        let base_name = base_tool_name(tool_name);
        let is_read_tool = base_name == tools::READ_FILE
            || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
        if !is_read_tool {
            return None;
        }

        let current_target = self
            .recent_calls
            .back()
            .and_then(|record| record.read_target.as_deref())?;

        let mut same_target_streak = 0usize;
        let mut variants = HashSet::new();
        for record in self.recent_calls.iter().rev() {
            if record.tool_name == tool_name
                && record.read_target.as_deref() == Some(current_target)
            {
                same_target_streak += 1;
                variants.insert(record.args_hash);
                continue;
            }
            break;
        }

        if same_target_streak >= MAX_SIMILAR_READ_TARGET_CALLS
            && variants.len() < MAX_SIMILAR_READ_TARGET_VARIANTS
        {
            let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
            self.tool_counts.insert(tool_name.to_string(), hard_limit);
            return Some(format!(
                "HARD STOP: Repeated '{}' calls for '{}' with minimal argument variation ({}-call streak, {} variants).",
                tool_name,
                current_target,
                same_target_streak,
                variants.len()
            ));
        }

        None
    }

    /// Check if hard limit is exceeded (should halt execution)
    pub fn is_hard_limit_exceeded(&self, tool_name: &str) -> bool {
        let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);
        let max_calls = self.get_limit_for_tool(tool_name);
        count >= max_calls * HARD_LIMIT_MULTIPLIER
    }

    /// Get current call count for a tool
    pub fn get_call_count(&self, tool_name: &str) -> usize {
        self.tool_counts.get(tool_name).copied().unwrap_or(0)
    }

    /// Reset tracking for specific tool (use after successful progress)
    pub fn reset_tool(&mut self, tool_name: &str) {
        self.tool_counts.remove(tool_name);
        self.recent_calls.retain(|r| r.tool_name != tool_name);
    }

    /// Suggest alternative approaches for common loop patterns
    /// Only called after loop detection, so `#[cold]`.
    #[cold]
    pub fn suggest_alternative(&self, tool_name: &str) -> Option<String> {
        match tool_name {
            LEGACY_LIST_FILES => Some(
                "Instead of listing files repeatedly:\n\
                 • Use unified_search with action='structural' plus lang for code patterns\n\
                 • Use unified_search with action='grep' for raw text, docs, or logs\n\
                 • Target specific subdirectories (e.g., 'src/', 'tests/')\n\
                 • Use unified_file with action='read' if you know the exact file path"
                    .to_string(),
            ),
            LEGACY_GREP_FILE => Some(
                "Instead of grepping repeatedly:\n\
                 • If syntax matters, switch to unified_search with action='structural' and set lang\n\
                 • Refine your text pattern or narrow the path when grep is the right tool\n\
                 • Use unified_file with action='read' to examine specific files\n\
                 • Consider using unified_exec with action='code' for complex filtering"
                    .to_string(),
            ),
            tools::READ_FILE => Some(
                "Instead of reading files repeatedly:\n\
                 • Use unified_search with action='structural' plus lang for code lookups\n\
                 • Use unified_search with action='grep' to find specific content first\n\
                 • Read specific line ranges with unified_file offset/limit parameters\n\
                 • Consider if you already have the information needed"
                    .to_string(),
            ),
            LEGACY_SEARCH_TOOLS => Some(
                "Instead of searching tools repeatedly:\n\
                 • Review the tools you've already discovered\n\
                 • Use unified_search with action='tools' to inspect available tools\n\
                 • Check if you need a different approach to the task"
                    .to_string(),
            ),
            _ => Some(
                "Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
                 • Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
                 • Breaking down the problem into smaller, verifiable sub-tasks\n\
                 • Checking if a recent change introduced a regression (run existing tests)\n\
                 • Asking for user guidance if strategic direction is ambiguous"
                    .to_string(),
            ),
        }
    }

    /// Get the number of tools currently being tracked
    pub fn get_tracked_tool_count(&self) -> usize {
        self.tool_counts.len()
    }

    pub fn reset(&mut self) {
        self.recent_calls.clear();
        self.tool_counts.clear();
        self.last_warning = None;
        self.norm_cache.clear();
        self.readonly_streak = 0;
    }

    /// Reset only the read-only streak counter without clearing tool call history.
    /// Used during stall recovery to allow the agent to try a different strategy
    /// while still detecting re-entry into the same looping pattern.
    pub fn reset_readonly_streak(&mut self) {
        self.readonly_streak = 0;
        self.last_warning = None;
    }

    /// Get limit for a specific tool.
    /// Checks custom limits first, then falls back to category defaults.
    #[inline]
    fn get_limit_for_tool(&self, tool_name: &str) -> usize {
        if let Some(&limit) = self.custom_limits.get(tool_name) {
            return limit;
        }
        let base_name = base_tool_name(tool_name);
        if let Some(&limit) = self.custom_limits.get(base_name) {
            return limit;
        }

        if base_name == tools::UNIFIED_FILE {
            if let Some((_, action)) = tool_name.split_once("::") {
                return if action.eq_ignore_ascii_case("read") {
                    MAX_READONLY_TOOL_CALLS
                } else {
                    MAX_WRITE_TOOL_CALLS
                };
            }
            return MAX_READONLY_TOOL_CALLS;
        }

        match base_name {
            tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::UNIFIED_SEARCH => {
                MAX_READONLY_TOOL_CALLS
            }
            tools::WRITE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => MAX_WRITE_TOOL_CALLS,
            _ if is_command_tool_name(base_name) => MAX_COMMAND_TOOL_CALLS,
            _ => MAX_OTHER_TOOL_CALLS,
        }
    }

    #[inline]
    fn should_enforce_identical_limit(tool_name: &str) -> bool {
        let base_name = base_tool_name(tool_name);
        !is_command_tool_name(base_name)
    }

    /// Suggest alternatives for stuck tools (extracted to static method for efficiency)
    /// Called only on the cold path (loop already detected); marked `#[cold]`
    /// and `#[inline(never)]` so LLVM does not inline it into the hot caller.
    #[cold]
    #[inline(never)]
    fn suggest_alternative_for_tool(tool_name: &str) -> String {
        match base_tool_name(tool_name) {
            LEGACY_LIST_FILES => "Instead of listing repeatedly:\n\
                 • Use unified_search with action='structural' plus lang for code patterns\n\
                 • Use unified_search with action='grep' for raw text, docs, or logs\n\
                 • Target specific subdirectories (e.g., 'src/', 'tests/')\n\
                 • Use unified_file with action='read' if you know the exact file path"
                .to_string(),
            LEGACY_GREP_FILE => "Instead of grepping repeatedly:\n\
                 • If syntax matters, switch to unified_search with action='structural' and set lang\n\
                 • Refine your text pattern or narrow the path when grep is the right tool\n\
                 • Use unified_file with action='read' to examine specific files\n\
                 • Consider using unified_exec with action='code' for complex filtering"
                .to_string(),
            tools::READ_FILE => "Instead of reading files repeatedly:\n\
                 • Use unified_search with action='structural' plus lang for code lookups\n\
                 • Use unified_search with action='grep' to find specific content first\n\
                 • Read specific line ranges with unified_file offset/limit parameters\n\
                 • Consider if you already have the information needed"
                .to_string(),
            LEGACY_SEARCH_TOOLS => "Instead of searching tools repeatedly:\n\
                 • Review the tools you've already discovered\n\
                 • Use unified_search with action='tools' to inspect available tools\n\
                 • Check if you need a different approach to the task"
                .to_string(),
            _ => "Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
                 • Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
                 • Breaking down the problem into smaller, verifiable sub-tasks\n\
                 • Checking if a recent change introduced a regression (run existing tests)\n\
                 • Asking for user guidance if strategic direction is ambiguous"
                .to_string(),
        }
    }

    /// Detect complex repetitive patterns (e.g. A -> B -> A -> B)
    fn detect_patterns(&self) -> Option<String> {
        let history: Vec<(&str, u64)> = self
            .recent_calls
            .iter()
            .map(|r| (r.tool_name.as_str(), r.args_hash))
            .collect();

        let len = history.len();
        if len < 4 {
            return None;
        }

        // Check for patterns of length K where 2*K <= len
        // We look for imminent repetition: [.. A, B, A, B]
        for k in 2..=(len / 2) {
            let suffix = &history[len - k..];
            let prev = &history[len - 2 * k..len - k];

            if suffix == prev {
                let pattern_desc: Vec<&str> = suffix.iter().map(|(name, _)| *name).collect();
                let pattern_str = pattern_desc.join(" -> ");

                return Some(format!(
                    "Repetitive pattern detected: [{}]\n\
                     The agent appears to be cycling through the same actions. \
                     Please pause and reassess the strategy.",
                    pattern_str
                ));
            }

            // Fuzzy detection: if tool names match but hashes differ, check semantic similarity?
            // For now, simpler fuzzy check: ignore edit_file content arguments?
            // Better: Detecting "oscillating" behavior A->B->A->B even if args slightly differ.
            // If tool names match exactly for a sequence of length >= 3
            let suffix_names: Vec<&str> = suffix.iter().map(|(n, _)| *n).collect();
            let prev_names: Vec<&str> = prev.iter().map(|(n, _)| *n).collect();

            if suffix_names == prev_names && k >= 2 {
                return Some(format!(
                    "Oscillating tool pattern detected: [{}]\n\
                     The agent is repeating the same sequence of tools. \
                     Ensure you are making actual progress.",
                    suffix_names.join(" -> ")
                ));
            }
        }

        None
    }
}

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

fn read_target_for_tool_call(tool_name: &str, args: &serde_json::Value) -> Option<String> {
    let base_name = base_tool_name(tool_name);
    let read_tool = base_name == tools::READ_FILE
        || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
    if !read_tool {
        return None;
    }

    let obj = args.as_object()?;
    for key in ["path", "file_path", "filepath", "target_path", "file"] {
        if let Some(path) = obj.get(key).and_then(|v| v.as_str()) {
            let trimmed = path.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    None
}

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

    #[test]
    fn test_immediate_repetition_detection() {
        let mut detector = LoopDetector::with_max_repeated_calls(3);
        let args = json!({"path": "src/"});

        // First two calls - no warning
        assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
        assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());

        // Third identical call - hard stop
        let warning = detector.record_call(LEGACY_GREP_FILE, &args);
        assert!(warning.is_some());
        assert!(warning.unwrap().contains("HARD STOP"));
    }

    #[test]
    fn test_command_tools_skip_identical_hard_stop() {
        let mut detector = LoopDetector::new();
        let args = json!({"command": "cargo test"});

        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
    }

    #[test]
    fn test_exec_command_alias_skips_identical_hard_stop() {
        let mut detector = LoopDetector::new();
        let args = json!({"cmd": "cargo test"});

        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
    }

    #[test]
    fn test_root_path_normalization() {
        let mut detector = LoopDetector::with_max_repeated_calls(3);

        // All these should be treated as identical
        let paths = [
            json!({"path": "."}),
            json!({"path": ""}),
            json!({"path": "././"}),
            json!({"path": "//"}),
            json!({}),
        ];

        for path in &paths[..2] {
            assert!(detector.record_call(LEGACY_LIST_FILES, path).is_none());
        }

        // Third call with any root variation should trigger
        let warning = detector.record_call(LEGACY_LIST_FILES, &paths[2]);
        assert!(warning.is_some());

        // Further root-only variations should continue to warn
        for path in &paths[3..] {
            assert!(detector.record_call(LEGACY_LIST_FILES, path).is_some());
        }
    }

    #[test]
    fn test_detects_repeated_calls() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_name = "test_repeated_tool";
        detector.set_tool_limit(tool_name, MAX_READONLY_TOOL_CALLS);
        let args = json!({"path": "/src"});

        // Repetition heuristics (pattern detection and soft limits) should warn eventually.
        let mut saw_warning = false;
        for _ in 0..MAX_READONLY_TOOL_CALLS {
            if detector.record_call(tool_name, &args).is_some() {
                saw_warning = true;
            }
        }
        assert!(saw_warning);
        assert_eq!(detector.get_call_count(tool_name), MAX_READONLY_TOOL_CALLS);
    }

    #[test]
    fn test_hard_limit_enforcement() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_name = "test_hard_limit_tool";
        detector.set_tool_limit(tool_name, 2);
        let args = json!({"pattern": "test"});

        // Hard limit is 2x configured soft limit.
        let hard_limit = 2 * HARD_LIMIT_MULTIPLIER;
        let mut saw_warning = false;
        for i in 0..hard_limit {
            let result = detector.record_call(tool_name, &args);
            if result.is_some() {
                saw_warning = true;
            }
            if i >= hard_limit - 1 {
                assert!(result.is_some());
            }
        }

        assert!(saw_warning);
        assert!(detector.is_hard_limit_exceeded(tool_name));
    }

    #[test]
    fn test_different_tools_no_warning() {
        let mut detector = LoopDetector::new();

        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "/src"}));
        detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));
        detector.record_call(tools::READ_FILE, &json!({"path": "main.rs"}));

        assert_eq!(detector.tool_counts.len(), 3);
    }

    #[test]
    fn test_non_root_paths_distinct() {
        let mut detector = LoopDetector::new();

        // These should be treated as different calls
        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "src"}));
        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "docs"}));
        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "tests"}));

        // Count for each should be 1
        assert_eq!(
            detector
                .tool_counts
                .get(LEGACY_LIST_FILES)
                .copied()
                .unwrap_or(0),
            3
        );
    }

    #[test]
    fn test_identical_calls_trigger_hard_limit() {
        let mut detector = LoopDetector::with_max_repeated_calls(3);
        let args = json!({"path": "."});

        assert!(detector.record_call(tools::READ_FILE, &args).is_none());
        assert!(detector.record_call(tools::READ_FILE, &args).is_none());

        let warning = detector.record_call(tools::READ_FILE, &args);
        assert!(warning.is_some());
        assert!(detector.is_hard_limit_exceeded(tools::READ_FILE));
    }

    #[test]
    fn test_normalize_args_removes_pagination() {
        let args = json!({"path": "src", "page": 1, "per_page": 10});
        let normalized = normalize_args_for_detection(LEGACY_LIST_FILES, &args);

        assert!(normalized.get("page").is_none());
        assert!(normalized.get("per_page").is_none());
        assert_eq!(normalized.get("path").and_then(|v| v.as_str()), Some("src"));
    }

    #[test]
    fn test_reset_tool_clears_specific_tool() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let args = json!({"path": "src"});

        // Record calls for multiple tools
        detector.record_call(LEGACY_LIST_FILES, &args);
        detector.record_call(LEGACY_LIST_FILES, &args);
        detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));

        assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 2);
        assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);

        // Reset only list_files
        detector.reset_tool(LEGACY_LIST_FILES);

        assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 0);
        assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);
    }

    #[test]
    fn test_suggest_alternative_for_list_files() {
        let detector = LoopDetector::new();
        let suggestion = detector.suggest_alternative(LEGACY_LIST_FILES);

        assert!(suggestion.is_some());
        let msg = suggestion.unwrap();
        assert!(msg.contains("unified_search"));
        assert!(msg.contains("action='structural'"));
        assert!(msg.contains("subdirectories"));
    }

    #[test]
    fn test_suggest_alternative_for_grep_file() {
        let detector = LoopDetector::new();
        let suggestion = detector.suggest_alternative(LEGACY_GREP_FILE);

        assert!(suggestion.is_some());
        let msg = suggestion.unwrap();
        assert!(msg.contains("unified_file"));
        assert!(msg.contains("set lang"));
        assert!(msg.contains("pattern"));
    }

    #[test]
    fn test_suggest_alternative_for_unknown_tool() {
        let detector = LoopDetector::new();
        let suggestion = detector.suggest_alternative("unknown_tool");

        assert!(suggestion.is_some());
        let msg = suggestion.unwrap();
        assert!(msg.contains("ROOT CAUSE analysis"));
    }

    #[test]
    fn test_faster_detection_with_lower_limit() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        detector.set_tool_limit(LEGACY_LIST_FILES, 3);
        let args = json!({"path": "src"});

        // First call - no warning
        assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());

        // Second call - no warning
        assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());

        // Third call - should trigger warning (soft limit = 3)
        let warning = detector.record_call(LEGACY_LIST_FILES, &args);
        assert!(warning.is_some());
        assert!(warning.unwrap().contains("Loop detected"));
    }

    #[test]
    fn test_unified_file_action_suffix_uses_action_specific_limit() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_key = format!("{}::read", tools::UNIFIED_FILE);

        for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
            let args = json!({"path": "src/main.rs", "offset_lines": idx + 1, "limit": 1});
            let _ = detector.record_call(&tool_key, &args);
        }

        // Read action should not use write limits.
        assert!(!detector.is_hard_limit_exceeded(&tool_key));
    }

    #[test]
    fn test_unified_file_write_suffix_uses_write_limit() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_key = format!("{}::write", tools::UNIFIED_FILE);

        for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
            let args = json!({"path": format!("src/file_{idx}.rs"), "content": "x"});
            let _ = detector.record_call(&tool_key, &args);
        }

        assert!(detector.is_hard_limit_exceeded(&tool_key));
    }

    #[test]
    fn test_unified_exec_action_suffix_skips_identical_limit() {
        let mut detector = LoopDetector::with_max_repeated_calls(3);
        let tool_key = format!("{}::run", tools::UNIFIED_EXEC);
        let args = json!({"command": "cargo check"});

        assert!(detector.record_call(&tool_key, &args).is_none());
        assert!(detector.record_call(&tool_key, &args).is_none());
        assert!(detector.record_call(&tool_key, &args).is_none());
    }

    #[test]
    fn test_repetitive_read_target_with_small_variations_triggers_hard_stop() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_key = format!("{}::read", tools::UNIFIED_FILE);
        let mut saw_hard_stop = false;

        for offset in [1, 2, 1, 2, 1, 2, 1, 2] {
            let args = json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": offset, "limit": 20});
            if let Some(warning) = detector.record_call(&tool_key, &args)
                && warning.contains("HARD STOP")
            {
                saw_hard_stop = true;
            }
        }

        assert!(saw_hard_stop);
        assert!(detector.is_hard_limit_exceeded(&tool_key));
    }

    #[test]
    fn test_repetitive_read_target_with_many_ranges_is_not_hard_stopped() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let tool_key = format!("{}::read", tools::UNIFIED_FILE);

        for offset in 1..=MAX_SIMILAR_READ_TARGET_CALLS {
            let args = json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": offset * 40, "limit": 40});
            if let Some(warning) = detector.record_call(&tool_key, &args) {
                assert!(!warning.contains("HARD STOP"));
            }
        }

        assert!(!detector.is_hard_limit_exceeded(&tool_key));
    }

    #[test]
    fn test_repetitive_read_target_requires_contiguous_streak() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let read_tool = format!("{}::read", tools::UNIFIED_FILE);

        for _ in 0..MAX_SIMILAR_READ_TARGET_CALLS {
            let _ = detector.record_call(
                &read_tool,
                &json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": 1, "limit": 20}),
            );
            let _ = detector.record_call(
                LEGACY_GREP_FILE,
                &json!({"pattern": "handle_loop_detection", "path": "vtcode-core/src"}),
            );
        }

        assert!(!detector.is_hard_limit_exceeded(&read_tool));
    }

    #[test]
    fn test_read_file_alias_cycling_triggers_identical_detection() {
        // Simulates the exact failure from the transcript: LLM cycles through
        // offset_lines, max_lines, chunk_lines, line_start/line_end, encoding
        // for the same file. Normalization should collapse them to identical hashes.
        let mut detector = LoopDetector::with_max_repeated_calls(3);

        let call1 = json!({"path": "docs/README.md", "max_lines": 200});
        let call2 = json!({"path": "docs/README.md", "offset_lines": 1, "limit": 200});
        let call3 = json!({"path": "docs/README.md", "chunk_lines": 200});

        // After normalization, all three should have: {path: "docs/README.md", offset: 1, limit: 200}
        let n1 = normalize_args_for_detection(tools::READ_FILE, &call1);
        let n2 = normalize_args_for_detection(tools::READ_FILE, &call2);
        let n3 = normalize_args_for_detection(tools::READ_FILE, &call3);

        // Verify aliases are normalized
        assert!(n1.get("max_lines").is_none(), "max_lines should be removed");
        assert!(
            n2.get("offset_lines").is_none(),
            "offset_lines should be removed"
        );
        assert!(
            n3.get("chunk_lines").is_none(),
            "chunk_lines should be removed"
        );
        assert_eq!(n1.get("limit"), n2.get("limit"));
        assert_eq!(n2.get("limit"), n3.get("limit"));

        // All three should trigger identical-call detection by call 3
        assert!(detector.record_call(tools::READ_FILE, &call1).is_none());
        assert!(detector.record_call(tools::READ_FILE, &call2).is_none());

        let warning = detector.record_call(tools::READ_FILE, &call3);
        assert!(warning.is_some(), "Third aliased call should be detected");
        assert!(warning.unwrap().contains("HARD STOP"));
    }

    #[test]
    fn test_read_file_encoding_and_action_are_stripped() {
        let with_encoding =
            json!({"path": "foo.rs", "encoding": "utf-8", "offset_lines": 1, "max_lines": 200});
        let without_encoding = json!({"path": "foo.rs", "offset_lines": 1, "max_lines": 200});

        let n1 = normalize_args_for_detection(tools::READ_FILE, &with_encoding);
        let n2 = normalize_args_for_detection(tools::READ_FILE, &without_encoding);

        assert!(n1.get("encoding").is_none());
        assert_eq!(n1, n2);
    }

    #[test]
    fn test_line_start_line_end_normalized_to_offset_limit() {
        let args = json!({"path": "foo.rs", "line_start": 1, "line_end": 200});
        let normalized = normalize_args_for_detection(tools::READ_FILE, &args);

        assert!(normalized.get("line_start").is_none());
        assert!(normalized.get("line_end").is_none());
        assert_eq!(normalized.get("offset").and_then(|v| v.as_u64()), Some(1));
        assert_eq!(normalized.get("limit").and_then(|v| v.as_u64()), Some(200));
    }

    #[test]
    fn test_navigation_loop_detection() {
        let mut detector = LoopDetector::with_max_repeated_calls(100);
        let list_args = serde_json::json!({"path": "src"});
        let grep_args = serde_json::json!({"pattern": "fn", "path": "src/main.rs"});
        let read_args = serde_json::json!({"path": "src/main.rs"});

        // Sequence: A, B, C, B, A (where A=LIST, B=GREP, C=READ)
        // This avoids identical patterns (k=2: [B, A] vs [B, C], k=3: [C, B, A] vs ???)
        let sequence = [
            (LEGACY_LIST_FILES, &list_args),
            (LEGACY_GREP_FILE, &grep_args),
            (tools::READ_FILE, &read_args),
            (LEGACY_GREP_FILE, &grep_args),
            (LEGACY_LIST_FILES, &list_args),
        ];

        for (i, (tool, args)) in sequence.iter().enumerate() {
            let res = detector.record_call(tool, args);
            assert!(
                res.is_none(),
                "Call {} ({}) should not have triggered a warning",
                i + 1,
                tool
            );
        }

        // 6th call (any read-only) should trigger navigation loop warning (streak hits 6)
        let warning = detector.record_call(tools::READ_FILE, &read_args);
        assert!(
            warning.is_some(),
            "6th call should have triggered a navigation loop warning"
        );
        assert!(warning.unwrap().contains("Navigation Loop Detected"));

        // A mutating call should reset the streak
        let write_args = serde_json::json!({"path": "src/new.rs", "content": "test"});
        assert!(
            detector
                .record_call(tools::WRITE_FILE, &write_args)
                .is_none()
        );

        // Subsequent read calls should start from 0; single call should be fine
        assert!(
            detector
                .record_call(LEGACY_LIST_FILES, &list_args)
                .is_none()
        );
    }
}
