use serde_json::Value;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;

// ── Ghost Ledger ──────────────────────────────────────────────────────────────

const MAX_GHOST_BACKUPS: usize = 8;

fn prune_ghost_backups(ghost_dir: &Path) {
    let Ok(entries) = fs::read_dir(ghost_dir) else {
        return;
    };

    let mut backups: Vec<_> = entries
        .filter_map(Result::ok)
        .filter(|entry| {
            entry
                .path()
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext.eq_ignore_ascii_case("bak"))
                .unwrap_or(false)
        })
        .collect();

    backups.sort_by_key(|entry| entry.metadata().and_then(|meta| meta.modified()).ok());
    backups.reverse();

    let retained: std::collections::HashSet<String> = backups
        .iter()
        .take(MAX_GHOST_BACKUPS)
        .map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
        .collect();

    for entry in backups.into_iter().skip(MAX_GHOST_BACKUPS) {
        let _ = fs::remove_file(entry.path());
    }

    let ledger_path = ghost_dir.join("ledger.txt");
    let Ok(content) = fs::read_to_string(&ledger_path) else {
        return;
    };

    let filtered_lines: Vec<String> = content
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(2, '|').collect();
            if parts.len() != 2 {
                return None;
            }

            let backup_path = parts[1].replace('\\', "/");
            if retained.contains(&backup_path) {
                Some(line.to_string())
            } else {
                None
            }
        })
        .collect();

    let rewritten = if filtered_lines.is_empty() {
        String::new()
    } else {
        filtered_lines.join("\n") + "\n"
    };
    let _ = fs::write(ledger_path, rewritten);
}

fn save_ghost_backup(target_path: &str, content: &str) {
    let ws = workspace_root();

    // Phase 1: Try Git Ghost Snapshot
    if crate::agent::git::is_git_repo(&ws) {
        let _ = crate::agent::git::create_ghost_snapshot(&ws);
    }

    // Phase 2: Fallback to local file backup (Ghost Ledger)
    let ghost_dir = hematite_dir().join("ghost");
    let _ = fs::create_dir_all(&ghost_dir);
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis();
    let safe_name = Path::new(target_path)
        .file_name()
        .unwrap_or_default()
        .to_string_lossy();
    let backup_file = ghost_dir.join(format!("{}_{}.bak", ts, safe_name));

    if fs::write(&backup_file, content).is_ok() {
        use std::io::Write;
        if let Ok(mut f) = fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(ghost_dir.join("ledger.txt"))
        {
            let _ = writeln!(f, "{}|{}", target_path, backup_file.display());
        }
        prune_ghost_backups(&ghost_dir);
    }
}

pub fn pop_ghost_ledger() -> Result<String, String> {
    let ghost_dir = hematite_dir().join("ghost");
    let ledger_path = ghost_dir.join("ledger.txt");

    if !ledger_path.exists() {
        return Err("Ghost Ledger is empty — no edits to undo".into());
    }

    let content = fs::read_to_string(&ledger_path).map_err(|e| e.to_string())?;
    let mut lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();

    if lines.is_empty() {
        return Err("Ghost Ledger is empty".into());
    }

    let last_line = lines.pop().unwrap();
    let parts: Vec<&str> = last_line.splitn(2, '|').collect();
    if parts.len() != 2 {
        return Err("Corrupted ledger entry".into());
    }

    let target_path = parts[0];
    let backup_path = parts[1];

    let ws = workspace_root();

    // Priority 1: Try Git Rollback
    if crate::agent::git::is_git_repo(&ws) {
        if let Ok(msg) = crate::agent::git::revert_from_ghost(&ws, target_path) {
            let _ = fs::remove_file(backup_path);
            let new_ledger = lines.join("\n");
            let _ = fs::write(
                &ledger_path,
                if new_ledger.is_empty() {
                    String::new()
                } else {
                    new_ledger + "\n"
                },
            );
            return Ok(msg);
        }
    }

    // Priority 2: Standard File Rollback
    let original_content =
        fs::read_to_string(backup_path).map_err(|e| format!("Failed to read backup: {e}"))?;
    let abs_target = ws.join(target_path);
    fs::write(&abs_target, original_content).map_err(|e| format!("Failed to restore file: {e}"))?;

    let new_ledger = lines.join("\n");
    let _ = fs::write(
        &ledger_path,
        if new_ledger.is_empty() {
            String::new()
        } else {
            new_ledger + "\n"
        },
    );
    let _ = fs::remove_file(backup_path);

    Ok(format!("Restored {} from Ghost Ledger", target_path))
}

// ── read_file ─────────────────────────────────────────────────────────────────

pub async fn read_file(args: &Value, budget_tokens: usize) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let offset = get_usize_arg(args, "offset");
    let limit = get_usize_arg(args, "limit");

    let abs = safe_path(path)?;
    let raw = fs::read_to_string(&abs).map_err(|e| format!("read_file: {e} ({path})"))?;

    let lines: Vec<&str> = raw.lines().collect();
    let total = lines.len();
    let start = offset.unwrap_or(0).min(total);
    let end = limit.map(|n| (start + n).min(total)).unwrap_or(total);

    let mut content = lines[start..end].join("\n");

    // Phase 5: Calculate predictive character budget based on remaining context.
    let budget_chars = budget_tokens.saturating_mul(4);
    let char_limit = if budget_tokens == 0 {
        100_000
    } else {
        budget_chars.min(100_000).max(2000)
    };

    if content.len() > char_limit {
        content.truncate(char_limit);
        content.push_str("\n\n--- [PREDICTIVE TRUNCATION: CONTEXT BUDGET REACHED] ---\n");
        content.push_str(&format!(
            "Output truncated at {} chars to prevent context window flooding. ",
            char_limit
        ));
        content
            .push_str("To see more, use `read_file` with a higher `offset` and a smaller `limit`.");
    } else if end < total {
        content.push_str("\n\n--- [TRUNCATION WARNING] ---\n");
        content.push_str(&format!("This file has {} more lines below. ", total - end));
        content.push_str("To read more, use `read_file` with a higher `offset` OR use `inspect_lines` to find relevant blocks. \
                         Do NOT attempt to read the entire large file at once if it keeps truncating.");
    }

    Ok(format!(
        "[{path}  lines {}-{} of {}]\n{}",
        start + 1,
        end,
        total,
        content
    ))
}

// ── inspect_lines ─────────────────────────────────────────────────────────────

pub async fn inspect_lines(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let start_line = get_usize_arg(args, "start_line").unwrap_or(1);
    let end_line = get_usize_arg(args, "end_line");

    let abs = safe_path(path)?;
    let raw = fs::read_to_string(&abs).map_err(|e| format!("inspect_lines: {e} ({path})"))?;

    let lines: Vec<&str> = raw.lines().collect();
    let total_lines = lines.len();

    // Out-of-bounds check with descriptive feedback.
    if start_line > total_lines && total_lines > 0 {
        return Err(format!(
            "Invalid line range: You requested line {}, but the file only has {} lines. Try `read_file` on a smaller range or the whole file.",
            start_line, total_lines
        ));
    }

    let start = start_line.saturating_sub(1).min(total_lines);
    let end = end_line.unwrap_or(total_lines).min(total_lines);

    if start >= end && total_lines > 0 {
        return Err(format!(
            "inspect_lines: start_line ({start_line}) must be <= end_line ({})",
            end_line.unwrap_or(total_lines)
        ));
    }

    let mut output = format!(
        "[inspect_lines: {path} lines {}-{} of {}]\n",
        start + 1,
        end,
        total_lines
    );
    for i in start..end {
        output.push_str(&format!("[{:>4}] | {}\n", i + 1, lines[i]));
    }

    Ok(output)
}

// ── tail_file ─────────────────────────────────────────────────────────────────

pub async fn tail_file(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let n = args
        .get("lines")
        .and_then(|v| v.as_u64())
        .unwrap_or(50)
        .min(500) as usize;
    let grep_pat = args.get("grep").and_then(|v| v.as_str());

    let abs = safe_path(path)?;
    let raw = fs::read_to_string(&abs).map_err(|e| format!("tail_file: {e} ({path})"))?;

    let all_lines: Vec<&str> = raw.lines().collect();
    let total = all_lines.len();

    // Apply optional grep filter before slicing — model asks for the last N
    // matching lines, not the last N lines containing maybe 0 matches.
    let filtered: Vec<(usize, &str)> = if let Some(pat) = grep_pat {
        let re = regex::Regex::new(pat)
            .map_err(|e| format!("tail_file: invalid grep pattern '{pat}': {e}"))?;
        all_lines
            .iter()
            .enumerate()
            .filter(|(_, l)| re.is_match(l))
            .map(|(i, l)| (i, *l))
            .collect()
    } else {
        all_lines.iter().enumerate().map(|(i, l)| (i, *l)).collect()
    };

    let total_filtered = filtered.len();
    let skip = total_filtered.saturating_sub(n);
    let window = &filtered[skip..];

    if window.is_empty() {
        let note = if grep_pat.is_some() {
            format!(" matching '{}'", grep_pat.unwrap())
        } else {
            String::new()
        };
        return Ok(format!(
            "[tail_file: {path} — no lines{note} found (total {total} lines)]"
        ));
    }

    let first_abs = window[0].0 + 1;
    let last_abs = window[window.len() - 1].0 + 1;
    let mut out = format!(
        "[tail_file: {path} — lines {first_abs}–{last_abs} of {total} (last {n} of {total_filtered} matched)]\n"
    );
    for (abs_idx, line) in window {
        out.push_str(&format!("[{:>5}] {}\n", abs_idx + 1, line));
    }

    Ok(out)
}

// ── write_file ────────────────────────────────────────────────────────────────

pub async fn write_file(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let content = require_str(args, "content")?;

    let abs = safe_path_allow_new(path)?;
    if let Some(parent) = abs.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("write_file: could not create dirs: {e}"))?;
    }

    let existed = abs.exists();
    if existed {
        if let Ok(orig) = fs::read_to_string(&abs) {
            save_ghost_backup(path, &orig);
        }
    }

    fs::write(&abs, content).map_err(|e| format!("write_file: {e} ({path})"))?;

    let action = if existed { "Updated" } else { "Created" };
    Ok(format!("{action} {path}  ({} bytes)", content.len()))
}

// ── edit_file ─────────────────────────────────────────────────────────────────

pub async fn edit_file(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let search = require_str(args, "search")?;
    let replace = require_str(args, "replace")?;
    let replace_all = args
        .get("replace_all")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if search == replace {
        return Err("edit_file: 'search' and 'replace' are identical — no change needed".into());
    }

    let abs = safe_path(path)?;
    let raw = fs::read_to_string(&abs).map_err(|e| format!("edit_file: {e} ({path})"))?;
    // Normalize CRLF → LF so search strings from the model (always LF) match on Windows.
    let original = raw.replace("\r\n", "\n");

    save_ghost_backup(path, &original);

    let search_trimmed = search.trim();
    let search_non_ws_len = search_trimmed
        .chars()
        .filter(|c| !c.is_whitespace())
        .count();
    let search_line_count = search_trimmed.lines().count();
    if search_non_ws_len < 12 && search_line_count <= 1 {
        return Err(format!(
            "edit_file: search string is too short or generic for a safe mutation in {path}.\n\
             Provide a more specific anchor (prefer a full line, multiple lines, or use `inspect_lines` + `patch_hunk`)."
        ));
    }

    // ── Exact match first ────────────────────────────────────────────────────
    let (effective_search, was_repaired) = if original.contains(search) {
        let exact_match_count = original.matches(search).count();
        if exact_match_count > 1 && !replace_all {
            return Err(format!(
                "edit_file: search string matched {} times in {path}.\n\
                 Provide a more specific unique anchor or use `inspect_lines` + `patch_hunk`.",
                exact_match_count
            ));
        }
        (search.to_string(), false)
    } else {
        // ── Fuzzy repair: progressive normalisation ───────────────────────
        // Level 1: rstrip only — preserves indentation, strips trailing spaces.
        // Level 2: indent-flexible — dedent both sides, preserve relative structure.
        // Level 3: full strip — last resort before cross-file hint.
        let span = rstrip_find_span(&original, search)
            .or_else(|| indent_flexible_find_span(&original, search))
            .or_else(|| fuzzy_find_span(&original, search));
        match span {
            Some(span) => {
                let real_slice = original[span.clone()].to_string();
                (real_slice, true)
            }
            None => {
                let hint = nearest_lines(&original, search);
                let cross_hint = find_search_in_workspace(search, path)
                    .map(|found| format!("\nNote: search string found in '{found}' — did you mean to edit that file?"))
                    .unwrap_or_default();
                return Err(format!(
                    "edit_file: search string not found in {path}.\n\
                     The 'search' value must match the file content exactly \
                     (including whitespace/indentation).\n\
                     {hint}{cross_hint}"
                ));
            }
        }
    };

    // When a fuzzy match was used, adjust the replace string's indentation to
    // match the file's actual indent level (not the model's potentially-wrong indent).
    let effective_replace = if was_repaired {
        adjust_replace_indent(search, effective_search.as_str(), replace)
    } else {
        replace.to_string()
    };

    let updated = if replace_all {
        original.replace(effective_search.as_str(), effective_replace.as_str())
    } else {
        original.replacen(effective_search.as_str(), effective_replace.as_str(), 1)
    };

    fs::write(&abs, &updated).map_err(|e| format!("edit_file: write failed: {e}"))?;

    let removed = original.lines().count();
    let added = updated.lines().count();
    let repair_note = if was_repaired {
        "  [indent auto-corrected]"
    } else {
        ""
    };

    let mut diff_block = String::new();
    diff_block.push_str("\n--- DIFF \n");
    for line in effective_search.lines() {
        diff_block.push_str(&format!("- {}\n", line));
    }
    for line in effective_replace.lines() {
        diff_block.push_str(&format!("+ {}\n", line));
    }

    Ok(format!(
        "Edited {path}  ({} -> {} lines){repair_note}{}",
        removed, added, diff_block
    ))
}

// ── patch_hunk ────────────────────────────────────────────────────────────────

pub async fn patch_hunk(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let start_line = require_usize(args, "start_line")?;
    let end_line = require_usize(args, "end_line")?;
    let replacement = require_str(args, "replacement")?;

    let abs = safe_path(path)?;
    let original = fs::read_to_string(&abs).map_err(|e| format!("patch_hunk: {e} ({path})"))?;

    save_ghost_backup(path, &original);

    let lines: Vec<String> = original.lines().map(|s| s.to_string()).collect();
    let total = lines.len();

    if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
        return Err(format!(
            "patch_hunk: invalid line range {}-{} for file with {} lines",
            start_line, end_line, total
        ));
    }

    let mut updated_lines = Vec::new();
    // 0-indexed adjustment
    let s_idx = start_line - 1;
    let e_idx = end_line; // inclusive in current logic from 1-based start_line..end_line

    // 1. Lines before the hunk
    updated_lines.extend_from_slice(&lines[0..s_idx]);

    // 2. The hunk replacement
    for line in replacement.lines() {
        updated_lines.push(line.to_string());
    }

    // 3. Lines after the hunk
    if e_idx < total {
        updated_lines.extend_from_slice(&lines[e_idx..total]);
    }

    let updated_content = updated_lines.join("\n");
    fs::write(&abs, &updated_content).map_err(|e| format!("patch_hunk: write failed: {e}"))?;

    let mut diff = String::new();
    diff.push_str("\n--- HUNK DIFF ---\n");
    for i in s_idx..e_idx {
        diff.push_str(&format!("- {}\n", lines[i].trim_end()));
    }
    for line in replacement.lines() {
        diff.push_str(&format!("+ {}\n", line.trim_end()));
    }

    Ok(format!(
        "Patched {path} lines {}-{} ({} -> {} lines){}",
        start_line,
        end_line,
        (e_idx - s_idx),
        replacement.lines().count(),
        diff
    ))
}

// ── multi_search_replace ──────────────────────────────────────────────────────

#[derive(serde::Deserialize)]
struct SearchReplaceHunk {
    search: String,
    replace: String,
}

pub async fn multi_search_replace(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let hunks_val = args
        .get("hunks")
        .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;

    let hunks: Vec<SearchReplaceHunk> = serde_json::from_value(hunks_val.clone())
        .map_err(|e| format!("multi_search_replace: invalid hunks array: {e}"))?;

    if hunks.is_empty() {
        return Err("multi_search_replace: hunks array is empty".to_string());
    }

    let abs = safe_path(path)?;
    let raw =
        fs::read_to_string(&abs).map_err(|e| format!("multi_search_replace: {e} ({path})"))?;
    // Normalize CRLF → LF so search strings from the model (always LF) match on Windows.
    let original = raw.replace("\r\n", "\n");

    save_ghost_backup(path, &original);

    let mut current_content = original.clone();
    let mut diff = String::new();
    diff.push_str("\n--- SEARCH & REPLACE DIFF ---\n");

    let mut patched_hunks = 0;

    for (i, hunk) in hunks.iter().enumerate() {
        let match_count = current_content.matches(&hunk.search).count();

        let (effective_search, effective_replace) = if match_count == 1 {
            // Exact match — use as-is.
            (hunk.search.clone(), hunk.replace.clone())
        } else if match_count == 0 {
            // Progressive fuzzy fallback: rstrip → indent-flexible → full-strip.
            let span = rstrip_find_span(&current_content, &hunk.search)
                .or_else(|| indent_flexible_find_span(&current_content, &hunk.search))
                .or_else(|| fuzzy_find_span(&current_content, &hunk.search));
            match span {
                Some(span) => {
                    let real_slice = current_content[span].to_string();
                    let adjusted_replace =
                        adjust_replace_indent(&hunk.search, &real_slice, &hunk.replace);
                    (real_slice, adjusted_replace)
                }
                None => {
                    return Err(format!(
                        "multi_search_replace: hunk {} search string not found in file.",
                        i
                    ));
                }
            }
        } else {
            return Err(format!(
                "multi_search_replace: hunk {} search string matched {} times. Provide more context to make it unique.",
                i, match_count
            ));
        };

        diff.push_str(&format!("\n@@ Hunk {} @@\n", i + 1));
        for line in effective_search.lines() {
            diff.push_str(&format!("- {}\n", line.trim_end()));
        }
        for line in effective_replace.lines() {
            diff.push_str(&format!("+ {}\n", line.trim_end()));
        }

        current_content = current_content.replacen(&effective_search, &effective_replace, 1);
        patched_hunks += 1;
    }

    fs::write(&abs, &current_content)
        .map_err(|e| format!("multi_search_replace: write failed: {e}"))?;

    Ok(format!(
        "Modified {} hunks in {} using exact search-and-replace.{}",
        patched_hunks, path, diff
    ))
}

// ── list_files ────────────────────────────────────────────────────────────────

pub async fn list_files(args: &Value, budget: usize) -> Result<String, String> {
    let char_budget = budget * 4; // Approx tokens to chars
    let started = Instant::now();
    let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
    let ext_filter = args.get("extension").and_then(|v| v.as_str());

    let base = safe_path(base_str)?;

    let mut files: Vec<PathBuf> = Vec::new();
    let mut scanned_count = 0;
    for entry in WalkDir::new(&base).follow_links(false) {
        scanned_count += 1;
        if scanned_count > 25_000 {
            return Err("list_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
        }
        let entry = entry.map_err(|e| format!("list_files: {e}"))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let p = entry.path();

        // Skip hidden dirs / target / node_modules
        if path_has_hidden_segment(p) {
            continue;
        }

        if let Some(ext) = ext_filter {
            if p.extension().and_then(|s| s.to_str()) != Some(ext) {
                continue;
            }
        }
        files.push(p.to_path_buf());
    }

    // Sort by modification time (newest first).
    files.sort_by_key(|p| {
        fs::metadata(p)
            .and_then(|m| m.modified())
            .ok()
            .map(std::cmp::Reverse)
    });

    let mut current_chars = 0;
    let mut shown = Vec::new();
    let mut truncated_by_budget = false;

    let total_scanned = files.len();
    for f in files {
        let f_str = f.display().to_string();
        if current_chars + f_str.len() + 1 > char_budget {
            truncated_by_budget = true;
            break;
        }
        current_chars += f_str.len() + 1;
        shown.push(f_str);
        if shown.len() >= 200 {
            break;
        }
    }

    let truncated = total_scanned > shown.len();

    let ms = started.elapsed().as_millis();
    let mut out = format!(
        "{} file(s) in {}  ({ms}ms){}",
        shown.len(),
        base_str,
        if truncated {
            if truncated_by_budget {
                "  [truncated by token budget]"
            } else {
                "  [truncated at 200]"
            }
        } else {
            ""
        }
    );
    out.push('\n');
    out.push_str(&shown.join("\n"));
    Ok(out)
}

// ── create_directory ──────────────────────────────────────────────────────────

pub async fn create_directory(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let abs = safe_path_allow_new(path)?;

    if abs.exists() {
        if abs.is_dir() {
            return Ok(format!("Directory already exists: {path}"));
        } else {
            return Err(format!("A file already exists at this path: {path}"));
        }
    }

    fs::create_dir_all(&abs).map_err(|e| format!("create_directory: {e} ({path})"))?;
    Ok(format!("Created directory: {path}"))
}

// ── grep_files ────────────────────────────────────────────────────────────────

pub async fn grep_files(args: &Value, budget: usize) -> Result<String, String> {
    let char_budget = budget * 4;
    let pattern = require_str(args, "pattern")?;
    let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
    let ext_filter = args.get("extension").and_then(|v| v.as_str());
    let case_insensitive = args
        .get("case_insensitive")
        .and_then(|v| v.as_bool())
        .unwrap_or(true);
    let files_only = args.get("mode").and_then(|v| v.as_str()) == Some("files_only");
    let head_limit = get_usize_arg(args, "head_limit").unwrap_or(50);
    let offset = get_usize_arg(args, "offset").unwrap_or(0);

    // Context lines: `context` sets both before+after; `before`/`after` override individually.
    let ctx_default = get_usize_arg(args, "context").unwrap_or(0);
    let before = get_usize_arg(args, "before").unwrap_or(ctx_default);
    let after = get_usize_arg(args, "after").unwrap_or(ctx_default);

    let base = safe_path(base_str)?;

    let regex = regex::RegexBuilder::new(pattern)
        .case_insensitive(case_insensitive)
        .build()
        .map_err(|e| format!("grep_files: invalid pattern '{pattern}': {e}"))?;

    // ── files_only mode ───────────────────────────────────────────────────────
    if files_only {
        let mut matched_files: Vec<String> = Vec::new();
        let mut scanned_count = 0;

        for entry in WalkDir::new(&base).follow_links(false) {
            scanned_count += 1;
            if scanned_count > 25_000 {
                return Err("grep_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
            }
            let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
            if !entry.file_type().is_file() {
                continue;
            }
            let p = entry.path();
            if path_has_hidden_segment(p) {
                continue;
            }
            if let Some(ext) = ext_filter {
                if p.extension().and_then(|s| s.to_str()) != Some(ext) {
                    continue;
                }
            }
            let Ok(contents) = fs::read_to_string(p) else {
                continue;
            };
            if contents.lines().any(|line| regex.is_match(line)) {
                matched_files.push(p.display().to_string());
            }
        }

        if matched_files.is_empty() {
            return Ok(format!("No files matching '{pattern}' in {base_str}"));
        }

        let total = matched_files.len();
        let page: Vec<_> = matched_files
            .into_iter()
            .skip(offset)
            .take(head_limit)
            .collect();
        let showing = page.len();

        let mut out = format!("{total} file(s) match '{pattern}'");
        if offset > 0 || showing < total {
            out.push_str(&format!(
                " [showing {}-{} of {total}]",
                offset + 1,
                offset + showing
            ));
        }
        out.push('\n');

        let mut current_chars = out.len();
        let mut shown_pages = Vec::new();
        for p in page {
            if current_chars + p.len() + 1 > char_budget {
                out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
                break;
            }
            current_chars += p.len() + 1;
            shown_pages.push(p);
        }
        out.push_str(&shown_pages.join("\n"));
        return Ok(out);
    }

    // ── content mode with optional context lines ──────────────────────────────

    // A "hunk" is a contiguous run of lines to display for one or more nearby matches.
    struct Hunk {
        path: String,
        /// (line_number_1_indexed, line_text, is_match)
        lines: Vec<(usize, String, bool)>,
    }

    let mut hunks: Vec<Hunk> = Vec::new();
    let mut total_matches = 0usize;
    let mut files_matched = 0usize;
    let mut scanned_count = 0;

    for entry in WalkDir::new(&base).follow_links(false) {
        scanned_count += 1;
        if scanned_count > 25_000 {
            return Err("grep_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
        }
        let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let p = entry.path();
        if path_has_hidden_segment(p) {
            continue;
        }
        if let Some(ext) = ext_filter {
            if p.extension().and_then(|s| s.to_str()) != Some(ext) {
                continue;
            }
        }
        let Ok(contents) = fs::read_to_string(p) else {
            continue;
        };
        let all_lines: Vec<&str> = contents.lines().collect();
        let n = all_lines.len();

        // Find all match indices in this file.
        let match_idxs: Vec<usize> = all_lines
            .iter()
            .enumerate()
            .filter(|(_, line)| regex.is_match(line))
            .map(|(i, _)| i)
            .collect();

        if match_idxs.is_empty() {
            continue;
        }
        files_matched += 1;
        total_matches += match_idxs.len();

        // Merge overlapping ranges into hunks.
        let path_str = p.display().to_string();
        let mut ranges: Vec<(usize, usize)> = match_idxs
            .iter()
            .map(|&i| {
                (
                    i.saturating_sub(before),
                    (i + after).min(n.saturating_sub(1)),
                )
            })
            .collect();

        // Sort and merge overlapping ranges.
        ranges.sort_unstable();
        let mut merged: Vec<(usize, usize)> = Vec::new();
        for (s, e) in ranges {
            if let Some(last) = merged.last_mut() {
                if s <= last.1 + 1 {
                    last.1 = last.1.max(e);
                    continue;
                }
            }
            merged.push((s, e));
        }

        // Build hunks from merged ranges.
        let match_set: std::collections::HashSet<usize> = match_idxs.into_iter().collect();
        for (start, end) in merged {
            let mut hunk_lines = Vec::new();
            for i in start..=end {
                hunk_lines.push((i + 1, all_lines[i].to_string(), match_set.contains(&i)));
            }
            hunks.push(Hunk {
                path: path_str.clone(),
                lines: hunk_lines,
            });
        }
    }

    if hunks.is_empty() {
        return Ok(format!("No matches for '{pattern}' in {base_str}"));
    }

    let total_hunks = hunks.len();
    let page_hunks: Vec<_> = hunks.into_iter().skip(offset).take(head_limit).collect();
    let showing = page_hunks.len();

    let mut out =
        format!("{total_matches} match(es) across {files_matched} file(s), {total_hunks} hunk(s)");
    if offset > 0 || showing < total_hunks {
        out.push_str(&format!(
            " [hunks {}-{} of {total_hunks}]",
            offset + 1,
            offset + showing
        ));
    }
    out.push('\n');

    let mut current_chars = out.len();
    let mut truncated_by_budget = false;

    for (i, hunk) in page_hunks.iter().enumerate() {
        let mut hunk_out = String::new();
        if i > 0 {
            hunk_out.push_str("\n--\n");
        }
        for (lineno, text, is_match) in &hunk.lines {
            if *is_match {
                hunk_out.push_str(&format!("{}:{}:{}\n", hunk.path, lineno, text));
            } else {
                hunk_out.push_str(&format!("{}: {}-{}\n", hunk.path, lineno, text));
            }
        }

        if current_chars + hunk_out.len() > char_budget {
            truncated_by_budget = true;
            break;
        }
        current_chars += hunk_out.len();
        out.push_str(&hunk_out);
    }

    if truncated_by_budget {
        out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
    }

    Ok(out.trim_end().to_string())
}

// ── Argument helpers ──────────────────────────────────────────────────────────

fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
    args.get(key)
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("Missing required argument: '{key}'"))
}

fn get_usize_arg(args: &Value, key: &str) -> Option<usize> {
    args.get(key).and_then(value_as_usize)
}

fn require_usize(args: &Value, key: &str) -> Result<usize, String> {
    get_usize_arg(args, key).ok_or_else(|| format!("Missing required numeric argument: '{key}'"))
}

fn value_as_usize(value: &Value) -> Option<usize> {
    if let Some(v) = value.as_u64() {
        return usize::try_from(v).ok();
    }

    if let Some(v) = value.as_i64() {
        return if v >= 0 {
            usize::try_from(v as u64).ok()
        } else {
            None
        };
    }

    if let Some(v) = value.as_f64() {
        if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= (usize::MAX as f64) {
            return Some(v as usize);
        }
        return None;
    }

    value.as_str().and_then(|s| s.trim().parse::<usize>().ok())
}

// ── Path helpers ──────────────────────────────────────────────────────────────

/// Resolve a path that must already exist, and check it's inside the workspace.
fn safe_path(path: &str) -> Result<PathBuf, String> {
    let candidate = resolve_candidate(path);
    match canonicalize_safe(&candidate, path) {
        Ok(abs) => Ok(abs),
        Err(e) => {
            if e.contains("The system cannot find the file specified") || e.contains("os error 2") {
                if let Some(suggestion) = suggest_better_path(path) {
                    return Err(format!("{e}. Did you mean '{suggestion}'?"));
                }
            }
            Err(e)
        }
    }
}

fn suggest_better_path(original: &str) -> Option<String> {
    let path = Path::new(original);
    let filename = path.file_name()?.to_str()?.to_lowercase();
    let parent = path.parent().unwrap_or_else(|| Path::new("."));

    // Use resolve_candidate to handle sovereign tokens like @DESKTOP/
    let abs_parent = resolve_candidate(&parent.to_string_lossy())
        .canonicalize()
        .ok()?;

    let mut best_match = None;
    let mut best_score = 0;

    if let Ok(entries) = fs::read_dir(abs_parent) {
        for entry in entries.flatten() {
            if let Some(candidate_name) = entry.file_name().to_str() {
                let lower_candidate = candidate_name.to_lowercase();
                if lower_candidate == filename {
                    continue;
                }

                let mut score = 0;
                if lower_candidate.starts_with(&filename) || filename.starts_with(&lower_candidate)
                {
                    score += 10;
                }
                // Catch style.css vs styles.css
                if (filename.ends_with('s') && filename[..filename.len() - 1] == lower_candidate)
                    || (lower_candidate.ends_with('s')
                        && lower_candidate[..lower_candidate.len() - 1] == filename)
                {
                    score += 20;
                }

                if score > best_score {
                    best_score = score;
                    best_match = Some(candidate_name.to_string());
                }
            }
        }
    }

    if best_score >= 10 {
        best_match
    } else {
        None
    }
}

/// Resolve a path that may not exist yet (for write_file).
fn safe_path_allow_new(path: &str) -> Result<PathBuf, String> {
    let candidate = resolve_candidate(path);

    // Try canonical first.
    if let Ok(abs) = candidate.canonicalize() {
        check_workspace_bounds(&abs, path)?;
        return Ok(abs);
    }

    // File doesn't exist yet — canonicalize the parent, append the filename.
    let parent = candidate.parent().unwrap_or(Path::new("."));
    let name = candidate
        .file_name()
        .ok_or_else(|| format!("invalid path: {path}"))?;
    let abs_parent = parent
        .canonicalize()
        .map_err(|_| format!("safe_path: parent dir doesn't exist for {path}"))?;
    let abs = abs_parent.join(name);
    check_workspace_bounds(&abs, path)?;
    Ok(abs)
}

pub(crate) fn resolve_candidate(path: &str) -> PathBuf {
    // 1. Handle Special Sovereign Tokens
    let upper = path.to_uppercase();

    // Bare token support — matches exact names with or without @ prefix, with or without
    // trailing slash. Enables /cd downloads, /cd @DESKTOP, /cd ~ etc.
    let bare = upper.trim_end_matches('/').trim_start_matches('@');
    let bare_resolved = match bare {
        "DESKTOP" => dirs::desktop_dir(),
        "DOWNLOADS" | "DOWNLOAD" => dirs::download_dir(),
        "DOCUMENTS" | "DOCS" => dirs::document_dir(),
        "PICTURES" | "IMAGES" => dirs::picture_dir(),
        "VIDEOS" | "MOVIES" => dirs::video_dir(),
        "MUSIC" | "AUDIO" => dirs::audio_dir(),
        "HOME" => dirs::home_dir(),
        "TEMP" | "TMP" => Some(std::env::temp_dir()),
        "CACHE" => dirs::cache_dir(),
        "CONFIG" => dirs::config_dir(),
        "DATA" => dirs::data_dir(),
        _ => None,
    };
    // Also handle bare ~ and ~/ as home
    let bare_resolved = bare_resolved.or_else(|| {
        if path == "~" || path == "~/" {
            dirs::home_dir()
        } else {
            None
        }
    });
    if let Some(p) = bare_resolved {
        return p;
    }

    // Helper to resolve via dirs crate
    let resolved = if upper.starts_with("@DESKTOP/") {
        dirs::desktop_dir().map(|p| p.join(&path[9..]))
    } else if upper.starts_with("@DOCUMENTS/") {
        dirs::document_dir().map(|p| p.join(&path[11..]))
    } else if upper.starts_with("@DOWNLOADS/") {
        dirs::download_dir().map(|p| p.join(&path[11..]))
    } else if upper.starts_with("@PICTURES/") || upper.starts_with("@IMAGES/") {
        let offset = if upper.starts_with("@PICTURES/") {
            10
        } else {
            8
        };
        dirs::picture_dir().map(|p| p.join(&path[offset..]))
    } else if upper.starts_with("@VIDEOS/") || upper.starts_with("@MOVIES/") {
        let offset = if upper.starts_with("@VIDEOS/") { 8 } else { 8 };
        dirs::video_dir().map(|p| p.join(&path[offset..]))
    } else if upper.starts_with("@MUSIC/") || upper.starts_with("@AUDIO/") {
        let offset = if upper.starts_with("@MUSIC/") { 7 } else { 7 };
        dirs::audio_dir().map(|p| p.join(&path[offset..]))
    } else if upper.starts_with("@HOME/") || upper.starts_with("~/") {
        let offset = if upper.starts_with("@HOME/") { 6 } else { 2 };
        dirs::home_dir().map(|p| p.join(&path[offset..]))
    } else if upper.starts_with("@TEMP/") {
        Some(std::env::temp_dir().join(&path[6..]))
    } else if upper.starts_with("@CACHE/") {
        dirs::cache_dir().map(|p| p.join(&path[7..]))
    } else if upper.starts_with("@CONFIG/") {
        dirs::config_dir().map(|p| p.join(&path[8..]))
    } else if upper.starts_with("@DATA/") {
        dirs::data_dir().map(|p| p.join(&path[6..]))
    } else {
        None
    };

    if let Some(p) = resolved {
        return p;
    }

    // 2. Fallback to Standard Resolution
    let p = Path::new(path);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(p)
    }
}

fn canonicalize_safe(candidate: &Path, original: &str) -> Result<PathBuf, String> {
    let abs = candidate
        .canonicalize()
        .map_err(|e: io::Error| format!("safe_path: {e} ({original})"))?;
    check_workspace_bounds(&abs, original)?;
    Ok(abs)
}

fn is_allowed_plan_sidecar(workspace: &Path, abs: &Path) -> bool {
    let normalized = abs
        .to_string_lossy()
        .trim_start_matches(r"\\?\")
        .to_lowercase()
        .replace('\\', "/");
    let workspace_norm = workspace
        .to_string_lossy()
        .trim_start_matches(r"\\?\")
        .to_lowercase()
        .replace('\\', "/");

    if !normalized.starts_with(&workspace_norm) {
        return false;
    }

    normalized.ends_with("/.hematite/task.md")
        || normalized.ends_with("/.hematite/plan.md")
        || normalized.ends_with("/.hematite/walkthrough.md")
}

fn check_workspace_bounds(abs: &Path, original: &str) -> Result<(), String> {
    let workspace = std::env::current_dir().map_err(|e| format!("could not read cwd: {e}"))?;
    if is_allowed_plan_sidecar(&workspace, abs) {
        return Ok(());
    }

    // Delegate to the existing guard for blacklist + traversal checks.
    super::guard::path_is_safe(&workspace, abs)
        .map(|_| ())
        .map_err(|e| format!("file access denied for '{original}': {e}"))
}

/// Returns true if the path contains a segment that should be skipped (.git, target, node_modules, etc.)
fn path_has_hidden_segment(p: &Path) -> bool {
    p.components().any(|c| {
        let s = c.as_os_str().to_string_lossy();
        if s == ".hematite" || s == ".git" || s == "." || s == ".." {
            return false;
        }
        s.starts_with('.') || s == "target" || s == "node_modules" || s == "__pycache__"
    })
}

/// Show the lines nearest to where the search string *almost* matched,
/// so the model can see the real indentation/content and self-correct.
fn nearest_lines(content: &str, search: &str) -> String {
    // Try to find the best-matching line by the first non-empty search line.
    let first_search_line = search
        .lines()
        .map(|l| l.trim())
        .find(|l| !l.is_empty())
        .unwrap_or("");

    let lines: Vec<&str> = content.lines().collect();
    if lines.is_empty() {
        return "(file is empty)".into();
    }

    // Find the line in the file that contains the most chars from the search line.
    let best_idx = if first_search_line.is_empty() {
        0
    } else {
        lines
            .iter()
            .enumerate()
            .max_by_key(|(_, l)| {
                let lt = l.trim();
                // Score: length of longest common prefix after trimming.
                first_search_line
                    .chars()
                    .zip(lt.chars())
                    .take_while(|(a, b)| a == b)
                    .count()
            })
            .map(|(i, _)| i)
            .unwrap_or(0)
    };

    let start = best_idx.saturating_sub(3);
    let end = (best_idx + 5).min(lines.len());
    let snippet = lines[start..end]
        .iter()
        .enumerate()
        .map(|(i, l)| format!("{:>4} | {}", start + i + 1, l))
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "Nearest matching lines ({}:{}):\n{}",
        best_idx + 1,
        end,
        snippet
    )
}

/// Core span-mapping logic shared by both fuzzy match levels.
/// Given a normalisation function, finds `search` inside `content` after
/// applying that function to both, then maps the result back to a byte
/// range in the original (un-normalised) `content`.
fn find_span_normalised(
    content: &str,
    search: &str,
    normalise: impl Fn(&str) -> String,
) -> Option<std::ops::Range<usize>> {
    let norm_content = normalise(content);
    let norm_search = normalise(search)
        .trim_start_matches('\n')
        .trim_end_matches('\n')
        .to_string();

    if norm_search.is_empty() {
        return None;
    }

    let norm_pos = norm_content.find(&norm_search)?;

    let lines_before = norm_content[..norm_pos]
        .as_bytes()
        .iter()
        .filter(|&&b| b == b'\n')
        .count();
    let search_lines = norm_search
        .as_bytes()
        .iter()
        .filter(|&&b| b == b'\n')
        .count()
        + 1;

    let orig_lines: Vec<&str> = content.lines().collect();

    let mut current_pos = 0;
    for i in 0..lines_before {
        if i < orig_lines.len() {
            current_pos += orig_lines[i].len() + 1;
        }
    }
    let byte_start = current_pos;

    let mut byte_len = 0;
    for i in 0..search_lines {
        let idx = lines_before + i;
        if idx < orig_lines.len() {
            byte_len += orig_lines[idx].len();
            if i < search_lines - 1 {
                byte_len += 1;
            }
        }
    }

    if byte_start + byte_len > content.len() {
        return None;
    }

    let candidate = &content[byte_start..byte_start + byte_len];
    if normalise(candidate).trim_end_matches('\n') == norm_search.as_str() {
        Some(byte_start..byte_start + byte_len)
    } else {
        None
    }
}

/// Level 1 fuzzy: rstrip only — removes trailing whitespace per line but
/// preserves leading indentation. Catches trailing-space mismatches where
/// the model's indentation is actually correct.
fn rstrip_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
    find_span_normalised(content, search, |s| {
        s.lines()
            .map(|l| l.trim_end())
            .collect::<Vec<_>>()
            .join("\n")
    })
}

/// Level 2 fuzzy: indent-flexible — strips the minimum common leading whitespace
/// (dedent) from both search and candidate windows before comparing. Preserves
/// relative indentation structure so nested code remains distinguishable. Also
/// normalises tabs → 4 spaces so tab/space mismatches are tolerated.
fn indent_flexible_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
    let norm_search = dedent(search.trim_matches('\n'));
    if norm_search.trim().is_empty() {
        return None;
    }
    let search_line_count = norm_search.lines().count();
    let content_lines: Vec<&str> = content.lines().collect();
    if content_lines.len() < search_line_count {
        return None;
    }

    // Precompute byte start of each line (content is already LF-normalised).
    let mut line_starts: Vec<usize> = Vec::with_capacity(content_lines.len() + 1);
    let mut pos = 0usize;
    for line in &content_lines {
        line_starts.push(pos);
        pos += line.len() + 1; // +1 for '\n'
    }
    line_starts.push(pos);

    for start in 0..=(content_lines.len() - search_line_count) {
        let window = content_lines[start..start + search_line_count].join("\n");
        if dedent(&window) == norm_search {
            let byte_start = line_starts[start];
            let end_line = start + search_line_count;
            let byte_end = if end_line < content_lines.len() {
                line_starts[end_line] - 1 // exclude trailing '\n'
            } else {
                content.len()
            };
            return Some(byte_start..byte_end);
        }
    }
    None
}

/// Level 3 fuzzy: full strip — trims all leading and trailing whitespace
/// per line. Last resort before the cross-file hint error.
fn fuzzy_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
    find_span_normalised(content, search, |s| {
        s.lines().map(|l| l.trim()).collect::<Vec<_>>().join("\n")
    })
}

/// Scan source files in the workspace for a search string that failed to
/// match in the intended target file. Returns the first file path where
/// the string is found (after CRLF normalisation), capped at 100 files.
/// Used to generate a "did you mean this file?" hint in edit errors.
fn find_search_in_workspace(search: &str, skip_path: &str) -> Option<String> {
    let root = workspace_root();
    let norm_search = search.replace("\r\n", "\n");
    let mut checked = 0usize;

    let walker = ignore::WalkBuilder::new(&root)
        .hidden(true)
        .ignore(true)
        .git_ignore(true)
        .build();

    for entry in walker.flatten() {
        if checked >= 100 {
            break;
        }
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        if !matches!(
            ext,
            "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "c" | "cpp" | "h"
        ) {
            continue;
        }
        let rel = path
            .strip_prefix(&root)
            .unwrap_or(path)
            .to_string_lossy()
            .replace('\\', "/");
        if rel == skip_path {
            continue;
        }
        checked += 1;
        if let Ok(content) = std::fs::read_to_string(path) {
            let normalised = content.replace("\r\n", "\n");
            if normalised.contains(&norm_search) {
                return Some(rel);
            }
        }
    }
    None
}

// ── Indent-aware replacement ──────────────────────────────────────────────────

/// Strip minimum common leading whitespace from all non-empty lines and
/// normalise tabs to 4 spaces. Blank lines are reduced to empty strings.
/// Used by indent_flexible_find_span for canonical comparison.
fn dedent(s: &str) -> String {
    let expanded: Vec<String> = s.lines().map(|l| l.replace('\t', "    ")).collect();
    let min_indent = expanded
        .iter()
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.len() - l.trim_start_matches(' ').len())
        .min()
        .unwrap_or(0);
    expanded
        .iter()
        .map(|l| {
            if l.trim().is_empty() {
                String::new()
            } else {
                l.get(min_indent..).unwrap_or(l).trim_end().to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// When the model's search string has different indentation than the actual file
/// content (fuzzy match succeeded), apply the same indentation delta to the
/// replace string so the replacement lands with correct indentation.
///
/// Example: model wrote search/replace with 0-space indent, file uses 8 spaces.
/// Delta = +8. Every line of replace gets 8 spaces prepended.
fn adjust_replace_indent(search: &str, file_span: &str, replace: &str) -> String {
    fn first_indent(s: &str) -> usize {
        s.lines()
            .find(|l| !l.trim().is_empty())
            .map(|l| l.len() - l.trim_start_matches(' ').len())
            .unwrap_or(0)
    }

    let search_indent = first_indent(search);
    let file_indent = first_indent(file_span);

    if search_indent == file_indent {
        return replace.to_string();
    }

    let delta: i64 = file_indent as i64 - search_indent as i64;
    let trailing_newline = replace.ends_with('\n');

    let adjusted: Vec<String> = replace
        .lines()
        .map(|line| {
            if line.trim().is_empty() {
                // Preserve blank lines as-is
                line.to_string()
            } else {
                let current_indent = line.len() - line.trim_start_matches(' ').len();
                let new_indent = (current_indent as i64 + delta).max(0) as usize;
                format!("{}{}", " ".repeat(new_indent), line.trim_start_matches(' '))
            }
        })
        .collect();

    let mut result = adjusted.join("\n");
    if trailing_newline {
        result.push('\n');
    }
    result
}

// ── Diff preview helpers (read-only, no writes) ───────────────────────────────

/// Return a formatted diff string for an edit_file operation without applying it.
/// Lines prefixed "- " are removals, "+ " are additions.  Returns Err if the
/// search string cannot be located (caller falls through to normal tool dispatch).
pub fn compute_edit_file_diff(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let search = require_str(args, "search")?;
    let replace = require_str(args, "replace")?;

    let abs = safe_path(path)?;
    let raw = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
    let original = raw.replace("\r\n", "\n");

    let (effective_search, effective_replace): (String, String) = if original.contains(search) {
        (search.to_string(), replace.to_string())
    } else {
        let span = rstrip_find_span(&original, search)
            .or_else(|| indent_flexible_find_span(&original, search))
            .or_else(|| fuzzy_find_span(&original, search));
        match span {
            Some(span) => {
                let real_slice = original[span].to_string();
                let adjusted = adjust_replace_indent(search, &real_slice, replace);
                (real_slice, adjusted)
            }
            None => return Err("search string not found — diff preview unavailable".into()),
        }
    };

    let mut diff = String::new();
    for line in effective_search.lines() {
        diff.push_str(&format!("- {}\n", line));
    }
    for line in effective_replace.lines() {
        diff.push_str(&format!("+ {}\n", line));
    }
    Ok(diff)
}

/// Return a formatted diff string for a patch_hunk operation without applying it.
pub fn compute_patch_hunk_diff(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let start_line = require_usize(args, "start_line")?;
    let end_line = require_usize(args, "end_line")?;
    let replacement = require_str(args, "replacement")?;

    let abs = safe_path(path)?;
    let original = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
    let lines: Vec<&str> = original.lines().collect();
    let total = lines.len();

    if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
        return Err(format!(
            "patch_hunk: invalid line range {}-{} for file with {} lines",
            start_line, end_line, total
        ));
    }

    let s_idx = start_line - 1;
    let e_idx = end_line;

    let mut diff = format!("@@ lines {}-{} @@\n", start_line, end_line);
    for i in s_idx..e_idx {
        diff.push_str(&format!("- {}\n", lines[i].trim_end()));
    }
    for line in replacement.lines() {
        diff.push_str(&format!("+ {}\n", line.trim_end()));
    }
    Ok(diff)
}

/// Return a formatted diff string for a multi_search_replace operation without applying it.
pub fn compute_msr_diff(args: &Value) -> Result<String, String> {
    let hunks_val = args
        .get("hunks")
        .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;

    #[derive(serde::Deserialize)]
    struct PreviewHunk {
        search: String,
        replace: String,
    }
    let hunks: Vec<PreviewHunk> = serde_json::from_value(hunks_val.clone())
        .map_err(|e| format!("compute_msr_diff: invalid hunks: {e}"))?;

    let mut diff = String::new();
    for (i, hunk) in hunks.iter().enumerate() {
        if hunks.len() > 1 {
            diff.push_str(&format!("@@ hunk {} @@\n", i + 1));
        }
        for line in hunk.search.lines() {
            diff.push_str(&format!("- {}\n", line.trim_end()));
        }
        for line in hunk.replace.lines() {
            diff.push_str(&format!("+ {}\n", line.trim_end()));
        }
    }
    Ok(diff)
}

/// Compute a preview diff for write_file — shows the full new content as additions,
/// and any existing file content as removals. New files show only `+` lines.
pub fn compute_write_file_diff(args: &Value) -> Result<String, String> {
    let path = require_str(args, "path")?;
    let new_content = require_str(args, "content")?;

    let abs = safe_path(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
    let old_content = fs::read_to_string(&abs)
        .map(|s| s.replace("\r\n", "\n"))
        .unwrap_or_default();

    let mut diff = String::new();
    if !old_content.is_empty() {
        for line in old_content.lines() {
            diff.push_str(&format!("- {}\n", line));
        }
    }
    for line in new_content.lines() {
        diff.push_str(&format!("+ {}\n", line));
    }
    if diff.is_empty() {
        return Err("empty content — diff preview unavailable".into());
    }
    Ok(diff)
}

/// Resolve the workspace root by looking upward for common markers.
pub fn workspace_root() -> PathBuf {
    let mut current = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    loop {
        if current.join(".git").exists()
            || current.join("Cargo.toml").exists()
            || current.join("package.json").exists()
        {
            return current;
        }
        if !current.pop() {
            break;
        }
    }
    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}

/// Returns true if `path` is a known OS shortcut directory (Desktop, Downloads,
/// Documents, Pictures, Videos, Music). These directories should not accumulate
/// `.hematite/` workspace state — they use the global `~/.hematite/` instead.
pub fn is_os_shortcut_directory(path: &Path) -> bool {
    let candidates = [
        dirs::desktop_dir(),
        dirs::download_dir(),
        dirs::document_dir(),
        dirs::picture_dir(),
        dirs::video_dir(),
        dirs::audio_dir(),
    ];
    candidates
        .iter()
        .filter_map(|d| d.as_deref())
        .any(|d| d == path)
}

/// Returns the directory where Hematite's runtime state (`.hematite/`) should live.
///
/// - In sovereign OS directories (Desktop, Downloads, Documents, Pictures, Videos,
///   Music): returns `~/.hematite/` so no workspace folder is created there.
/// - Everywhere else: returns `workspace_root()/.hematite/` as normal.
pub fn hematite_dir() -> PathBuf {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    if is_os_shortcut_directory(&cwd) {
        if let Some(home) = dirs::home_dir() {
            return home.join(".hematite");
        }
    }
    workspace_root().join(".hematite")
}

/// Returns true if the workspace root looks like a real project.
/// A bare `.git` alone (e.g. accidental `git init` in the home folder) doesn't
/// count — at least one explicit build/package marker must also be present.
pub fn is_project_workspace() -> bool {
    let root = workspace_root();
    let has_explicit_marker = root.join("Cargo.toml").exists()
        || root.join("package.json").exists()
        || root.join("pyproject.toml").exists()
        || root.join("go.mod").exists()
        || root.join("setup.py").exists()
        || root.join("pom.xml").exists()
        || root.join("build.gradle").exists()
        || root.join("CMakeLists.txt").exists()
        || root.join("index.html").exists()
        || root.join("style.css").exists()
        || root.join("script.js").exists();
    has_explicit_marker || (root.join(".git").exists() && root.join("src").exists())
}

// ── open_in_system_editor ───────────────────────────────────────────────────

pub fn open_in_system_editor(path: &std::path::Path) -> Result<(), String> {
    if !path.exists() {
        return Err(format!("File not found: {}", path.display()));
    }

    #[cfg(target_os = "windows")]
    {
        // On Windows, 'start' is the most reliable way to open a file in the default associated app.
        // We use cmd /c start so it handles spaces and associations properly.
        let status = std::process::Command::new("cmd")
            .args(["/c", "start", "", &path.to_string_lossy()])
            .status()
            .map_err(|e| format!("Failed to launch editor: {e}"))?;

        if !status.success() {
            return Err("Editor command failed to start.".into());
        }
    }

    #[cfg(target_os = "macos")]
    {
        let status = std::process::Command::new("open")
            .arg(path)
            .status()
            .map_err(|e| format!("Failed to launch editor: {e}"))?;

        if !status.success() {
            return Err("open command failed.".into());
        }
    }

    #[cfg(all(unix, not(target_os = "macos")))]
    {
        // Try xdg-open on Linux
        let status = std::process::Command::new("xdg-open")
            .arg(path)
            .status()
            .map_err(|e| format!("Failed to launch editor: {e}"))?;

        if !status.success() {
            return Err("xdg-open failed.".into());
        }
    }

    Ok(())
}

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

    #[test]
    fn safe_path_allows_plan_sidecars_inside_workspace() {
        let temp = tempfile::tempdir().unwrap();
        let root = temp.path();
        std::fs::create_dir_all(root.join(".hematite")).unwrap();
        std::fs::write(root.join(".hematite").join("TASK.md"), "# Task Ledger\n").unwrap();

        let previous = std::env::current_dir().unwrap();
        std::env::set_current_dir(root).unwrap();
        let resolved = safe_path(".hematite/TASK.md").unwrap();
        std::env::set_current_dir(previous).unwrap();

        assert!(resolved.ends_with(Path::new(".hematite").join("TASK.md")));
    }
}
