use super::FileOpsTool;
use super::is_image_path;
use super::path_policy::PathSuggestionKind;
mod legacy;
mod logging;
mod segments;
use crate::config::constants::tools;
use crate::telemetry::perf::PerfSpan;
use crate::tools::builder::ToolResponseBuilder;
use crate::tools::cache::{FILE_CACHE, file_read_cache_config};
use crate::tools::continuation::{DEFAULT_NEXT_READ_LIMIT, ReadChunkContinuationArgs};
use crate::tools::handlers::read_file::{ReadFileArgs, ReadFileHandler, ReadFileOutcome};
use crate::tools::traits::FileTool;
use crate::tools::types::{Input, PathArgs};
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use std::path::Path;
use std::time::UNIX_EPOCH;

const SPOOL_CHUNK_DEFAULT_LIMIT_LINES: usize = DEFAULT_NEXT_READ_LIMIT;
const SPOOL_CHUNK_MAX_LIMIT_LINES: usize = 50;
const SPOOL_CHUNK_SENTINEL_MAX_TOKENS: usize = 4096;

#[derive(Clone, Copy, Debug)]
struct SpoolChunkPlan {
    offset: usize,
    limit: usize,
}

fn is_legacy_read_request(args: &Value, is_image: bool) -> bool {
    if is_image {
        return true;
    }

    if let Some(obj) = args.as_object() {
        obj.keys().any(|key| {
            matches!(
                key.as_str(),
                "offset_bytes"
                    | "page_size_bytes"
                    | "offset_lines"
                    | "page_size_lines"
                    | "max_bytes"
                    | "max_lines"
                    | "chunk_lines"
                    | "encoding"
                    | "max_tokens"
            )
        })
    } else {
        false
    }
}

fn is_new_read_request(args: &Value) -> bool {
    let new_keys = ["mode", "indentation", "offset", "limit", "o", "l"];
    new_keys.iter().any(|key| args.get(*key).is_some())
}

fn looks_like_patch_payload(args: &Value) -> bool {
    fn looks_like_patch_text(text: &str) -> bool {
        let trimmed = text.trim_start();
        trimmed.starts_with("*** Begin Patch")
            || trimmed.starts_with("*** Update File:")
            || trimmed.starts_with("*** Add File:")
            || trimmed.starts_with("*** Delete File:")
    }

    args.get("patch")
        .and_then(Value::as_str)
        .is_some_and(looks_like_patch_text)
        || args
            .get("input")
            .and_then(Value::as_str)
            .is_some_and(looks_like_patch_text)
        || args.as_str().is_some_and(looks_like_patch_text)
}

fn build_read_handler_args(args: &Value, canonical_path: &Path) -> Value {
    let mut handler_args_json = args.clone();
    if let Some(obj) = handler_args_json.as_object_mut() {
        for alias in ["path", "filepath", "target_path", "file"] {
            obj.remove(alias);
        }
        obj.insert(
            "file_path".to_string(),
            json!(canonical_path.to_string_lossy()),
        );

        if let Some(mode_value) = obj.get("mode").cloned() {
            let normalized_mode = match mode_value {
                Value::Null => None,
                Value::String(raw) => {
                    let trimmed = raw.trim();
                    if trimmed.eq_ignore_ascii_case("indentation") {
                        Some("indentation".to_string())
                    } else {
                        Some("slice".to_string())
                    }
                }
                _ => Some("slice".to_string()),
            };

            if let Some(mode) = normalized_mode {
                obj.insert("mode".to_string(), json!(mode));
            } else {
                obj.remove("mode");
            }
        }

        if let Some(indentation_value) = obj.get("indentation").cloned() {
            match indentation_value {
                Value::Bool(true) => {
                    obj.insert("mode".to_string(), json!("indentation"));
                    obj.insert("indentation".to_string(), json!({}));
                }
                Value::Bool(false) | Value::Null => {
                    obj.remove("indentation");
                    if obj
                        .get("mode")
                        .and_then(Value::as_str)
                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
                    {
                        obj.insert("mode".to_string(), json!("slice"));
                    }
                }
                Value::String(text) if text.eq_ignore_ascii_case("true") => {
                    obj.insert("mode".to_string(), json!("indentation"));
                    obj.insert("indentation".to_string(), json!({}));
                }
                Value::String(text) if text.eq_ignore_ascii_case("false") => {
                    obj.remove("indentation");
                    if obj
                        .get("mode")
                        .and_then(Value::as_str)
                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
                    {
                        obj.insert("mode".to_string(), json!("slice"));
                    }
                }
                Value::Object(_) => {
                    obj.insert("mode".to_string(), json!("indentation"));
                }
                _ => {}
            }
        }

        if let Some(src) = obj.get("offset_lines").or_else(|| obj.get("o")).cloned() {
            obj.entry("offset".to_string()).or_insert(src);
        }
        if let Some(src) = obj.get("page_size_lines").or_else(|| obj.get("l")).cloned() {
            obj.entry("limit".to_string()).or_insert(src);
        }

        if obj.get("offset").and_then(parse_usize_value) == Some(0) {
            obj.insert("offset".to_string(), json!(1));
        }
        if obj.get("limit").and_then(parse_usize_value) == Some(0) {
            obj.remove("limit");
        }
    }

    handler_args_json
}

fn parse_usize_value(value: &Value) -> Option<usize> {
    value
        .as_u64()
        .and_then(|n| usize::try_from(n).ok())
        .or_else(|| value.as_str().and_then(|s| s.parse::<usize>().ok()))
}

fn has_explicit_limit(args: &Value) -> bool {
    ["limit", "l", "page_size_lines", "max_lines", "chunk_lines"]
        .iter()
        .any(|key| args.get(*key).is_some())
}

fn has_explicit_offset(args: &Value) -> bool {
    ["offset", "o", "offset_lines", "offset_bytes"]
        .iter()
        .any(|key| args.get(*key).is_some())
}

fn is_full_text_read(args: &Value, is_spool_output: bool) -> bool {
    !is_spool_output && !has_explicit_offset(args) && !has_explicit_limit(args)
}

fn restore_exact_text_content(content: &str, size_bytes: u64) -> Option<String> {
    let content_size = content.len() as u64;
    match size_bytes.checked_sub(content_size) {
        Some(0) => Some(content.to_string()),
        Some(1) => Some(format!("{content}\n")),
        Some(2) if content.contains("\r\n") || !content.contains('\n') => {
            Some(format!("{content}\r\n"))
        }
        _ => None,
    }
}

fn response_size_bytes(response: &Value) -> Option<u64> {
    response
        .get("metadata")
        .and_then(|metadata| metadata.get("data"))
        .and_then(|data| data.get("size_bytes"))
        .and_then(Value::as_u64)
}

fn apply_spool_chunk_defaults(handler_args_json: &mut Value, raw_args: &Value) -> SpoolChunkPlan {
    let mut offset = 1usize;
    let mut limit = SPOOL_CHUNK_DEFAULT_LIMIT_LINES;

    if let Some(obj) = handler_args_json.as_object_mut() {
        offset = obj
            .get("offset")
            .and_then(parse_usize_value)
            .unwrap_or(1)
            .max(1);

        let requested_limit = if has_explicit_limit(raw_args) {
            raw_args
                .get("limit")
                .or_else(|| raw_args.get("l"))
                .or_else(|| raw_args.get("page_size_lines"))
                .or_else(|| raw_args.get("max_lines"))
                .or_else(|| raw_args.get("chunk_lines"))
                .and_then(parse_usize_value)
                .unwrap_or(SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
        } else {
            SPOOL_CHUNK_DEFAULT_LIMIT_LINES
        };

        limit = requested_limit.clamp(1, SPOOL_CHUNK_MAX_LIMIT_LINES);

        obj.insert("offset".to_string(), json!(offset));
        obj.insert("limit".to_string(), json!(limit));
        // Preserve narrow chunking behavior by bypassing MIN_BATCH_LIMIT expansion
        // in ReadFileHandler::handle (which only applies when max_tokens is absent).
        obj.entry("max_tokens".to_string())
            .or_insert_with(|| json!(SPOOL_CHUNK_SENTINEL_MAX_TOKENS));
    }

    SpoolChunkPlan { offset, limit }
}

fn is_history_jsonl(path: &Path) -> bool {
    // Fast path: avoid component iteration if extension doesn't match
    if !path.to_string_lossy().ends_with(".jsonl") {
        return false;
    }

    let s = path.to_string_lossy();
    s.contains(".vtcode") && s.contains("/history/")
}

fn is_tool_output_spool_path(path: &Path) -> bool {
    let s = path.to_string_lossy();
    s.contains(".vtcode") && s.contains("/context/tool_outputs/")
}

fn pty_session_id_from_tool_output_path(path: &Path) -> Option<String> {
    let file_name = path.file_name()?.to_str()?;
    let session_id = file_name.strip_suffix(".txt")?;
    if session_id.starts_with("run-")
        && session_id.len() > "run-".len()
        && session_id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        Some(session_id.to_string())
    } else {
        None
    }
}

fn build_history_cache_key(
    path: &Path,
    metadata: &std::fs::Metadata,
    args: &Value,
) -> Option<String> {
    let modified = metadata.modified().ok()?;
    let mtime = modified.duration_since(UNIX_EPOCH).ok()?.as_millis();
    let size = metadata.len();

    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("legacy");
    let indentation = args
        .get("indentation")
        .and_then(Value::as_str)
        .unwrap_or("");
    let offset = args
        .get("offset")
        .or_else(|| args.get("o"))
        .or_else(|| args.get("offset_lines"))
        .or_else(|| args.get("offset_bytes"))
        .and_then(Value::as_u64)
        .unwrap_or(0);
    let limit = args
        .get("limit")
        .or_else(|| args.get("l"))
        .or_else(|| args.get("page_size_lines"))
        .or_else(|| args.get("page_size_bytes"))
        .and_then(Value::as_u64)
        .unwrap_or(0);
    let max_bytes = args.get("max_bytes").and_then(Value::as_u64).unwrap_or(0);
    let max_tokens = args.get("max_tokens").and_then(Value::as_u64).unwrap_or(0);
    let encoding = args.get("encoding").and_then(Value::as_str).unwrap_or("");

    Some(format!(
        "read_file:history:{}:{}:{}:mode={mode}|indent={indentation}|offset={offset}|limit={limit}|max_bytes={max_bytes}|max_tokens={max_tokens}|encoding={encoding}",
        path.display(),
        size,
        mtime
    ))
}

impl FileOpsTool {
    async fn track_read_snapshot(&self, path: &Path) {
        if let Err(err) = self.edited_file_monitor.track_read(path).await {
            tracing::warn!(
                path = %path.display(),
                error = %err,
                "Failed to record edited-file read snapshot"
            );
        }
    }

    fn track_read_text_snapshot(&self, path: &Path, content: &str) {
        if let Err(err) = self.edited_file_monitor.record_read_text(path, content) {
            tracing::warn!(
                path = %path.display(),
                error = %err,
                "Failed to record edited-file read snapshot"
            );
        }
    }

    async fn track_exact_text_snapshot(&self, path: &Path, content: &str, size_bytes: u64) {
        if let Some(exact_content) = restore_exact_text_content(content, size_bytes) {
            self.track_read_text_snapshot(path, &exact_content);
        } else {
            self.track_read_snapshot(path).await;
        }
    }

    async fn track_cached_read_snapshot(&self, path: &Path, args: &Value, response: &Value) {
        if is_full_text_read(args, false)
            && let Some(content) = response.get("content").and_then(Value::as_str)
            && let Some(size_bytes) = response_size_bytes(response)
        {
            self.track_exact_text_snapshot(path, content, size_bytes)
                .await;
            return;
        }

        self.track_read_snapshot(path).await;
    }

    pub async fn read_file(&self, args: Value) -> Result<Value> {
        let mut perf = PerfSpan::new("vtcode.perf.read_file_ms");

        let path_args: PathArgs = serde_json::from_value(args.clone()).map_err(|_| {
            if looks_like_patch_payload(&args) {
                return anyhow!(
                    "Error: Patch content was sent to read_file.\n\
                    Use the patch path instead: unified_file with {{\"action\":\"patch\",\"patch\":\"...\"}} \
                    (or {{\"action\":\"patch\",\"input\":\"...\"}}).\n\
                    read_file requires a path parameter."
                );
            }
            let received = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
            anyhow!(
                "Error: Invalid 'read_file' arguments. Missing required path parameter.\n\
                Received: {}\n\
                Expected: {{\"path\": \"file/path\"}} or {{\"file_path\": \"file/path\"}}\n\
                Accepted path parameters: path, file_path, filepath, target_path, file, p\n\
                Optional params: offset_lines, limit, max_bytes, max_tokens",
                received
            )
        })?;

        let path_str = &path_args.path;

        // Try to resolve the file path
        let potential_paths = self.resolve_file_path(path_str)?;
        let missing_spool_candidate = potential_paths
            .iter()
            .find(|candidate| is_tool_output_spool_path(candidate.as_path()))
            .cloned();
        let mut directory_candidate = None;

        for candidate_path in &potential_paths {
            if !tokio::fs::try_exists(candidate_path).await? {
                continue;
            }

            let canonical = self
                .normalize_and_validate_candidate(candidate_path, path_str)
                .await?;

            if self.should_exclude(&canonical).await {
                continue;
            }

            let metadata = tokio::fs::metadata(&canonical).await?;
            if !metadata.is_file() {
                if metadata.is_dir() && directory_candidate.is_none() {
                    directory_candidate = Some(canonical);
                }
                continue;
            }

            let size_bytes = metadata.len();
            let history_jsonl = is_history_jsonl(&canonical);
            perf.tag(
                "path_class",
                if history_jsonl {
                    "history_jsonl"
                } else {
                    "other"
                },
            );
            let is_image = is_image_path(&canonical);
            let is_spool_output = is_tool_output_spool_path(&canonical);
            let is_legacy_request = is_legacy_read_request(&args, is_image);
            let is_new_request = is_new_read_request(&args);

            let cache_config = file_read_cache_config();
            let cache_key = if cache_config.enabled
                && history_jsonl
                && size_bytes >= cache_config.min_size_bytes as u64
                && size_bytes <= cache_config.max_size_bytes as u64
            {
                build_history_cache_key(&canonical, &metadata, &args)
            } else {
                None
            };

            if let Some(key) = cache_key.as_ref()
                && let Some(cached) = FILE_CACHE.get_file(key).await
            {
                perf.tag("cache", "hit");
                self.track_cached_read_snapshot(&canonical, &args, &cached)
                    .await;
                return Ok(cached);
            }
            perf.tag("cache", if cache_key.is_some() { "miss" } else { "skip" });

            if !is_image && (is_new_request || !is_legacy_request) {
                let mut handler_args_json = build_read_handler_args(&args, &canonical);
                let spool_plan = if is_spool_output {
                    Some(apply_spool_chunk_defaults(&mut handler_args_json, &args))
                } else {
                    None
                };
                match serde_json::from_value::<ReadFileArgs>(handler_args_json) {
                    Ok(read_args) => {
                        let requested_path = self.workspace_relative_display(&canonical);
                        let handler = ReadFileHandler;
                        let ReadFileOutcome {
                            content,
                            lines_read: lines_returned,
                            has_more,
                        } = handler.handle_detailed(read_args).await?;
                        let full_text_read = is_full_text_read(&args, is_spool_output);
                        let response_content = if full_text_read {
                            restore_exact_text_content(&content, size_bytes)
                                .unwrap_or_else(|| content.clone())
                        } else {
                            content.clone()
                        };

                        let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
                            .success()
                            .message(format!("Successfully read file {}", requested_path))
                            .content(&response_content)
                            .field("path", json!(requested_path.clone()))
                            .field("no_spool", json!(true))
                            .field("content_kind", json!("text"))
                            .field("encoding", json!("utf8"))
                            .data("size_bytes", json!(size_bytes))
                            .data("content_kind", json!("text"))
                            .data("encoding", json!("utf8"));

                        if let Some(plan) = spool_plan {
                            let next_offset = plan.offset.saturating_add(lines_returned);

                            builder = builder
                                .field("spool_chunked", json!(true))
                                .field("lines_returned", json!(lines_returned));
                            if has_more {
                                builder = builder.field("has_more", json!(true));
                                builder = builder.field(
                                    "next_read_args",
                                    ReadChunkContinuationArgs::new(
                                        requested_path.clone(),
                                        next_offset,
                                        plan.limit,
                                    )
                                    .to_value(),
                                );
                            }
                        }

                        let response = builder.build_json();

                        if full_text_read {
                            self.track_exact_text_snapshot(
                                &canonical,
                                &response_content,
                                size_bytes,
                            )
                            .await;
                        } else {
                            self.track_read_snapshot(&canonical).await;
                        }
                        if let Some(key) = cache_key.as_ref() {
                            FILE_CACHE.put_file(key.clone(), response.clone()).await;
                        }

                        return Ok(response);
                    }
                    Err(e) => {
                        if is_new_request {
                            return Err(anyhow!(
                                "Failed to parse arguments for read_file handler: {}. Args: {:?}",
                                e,
                                args
                            ));
                        }
                    }
                }
            }

            // Legacy path
            let input: Input = serde_json::from_value(args.clone())
                .context("Error: Invalid 'read_file' arguments for legacy handler.")?;

            // Check if paging/offset is requested
            let use_paging = input.offset_bytes.is_some()
                || input.page_size_bytes.is_some()
                || input.offset_lines.is_some()
                || input.page_size_lines.is_some();

            let (content, metadata, truncated) = if use_paging {
                self.read_file_paged(&canonical, &input).await?
            } else {
                self.read_file_legacy(&canonical, &input).await?
            };

            let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
                .success()
                .message(format!(
                    "Successfully read {} bytes from {}",
                    size_bytes,
                    self.workspace_relative_display(&canonical)
                ))
                .content(&content)
                .field("path", json!(self.workspace_relative_display(&canonical)))
                .field("no_spool", json!(true));

            // Merge legacy metadata - extract actual data fields, not wrapper structure
            if let Some(obj) = metadata.as_object() {
                for (k, v) in obj {
                    builder = builder.data(k.clone(), v.clone());
                }
            }

            // Special legacy fields at top level
            if let Some(is_truncated) = metadata.get("is_truncated").and_then(Value::as_bool) {
                builder = builder.field("is_truncated", json!(is_truncated));
            }
            if let Some(encoding) = metadata.get("encoding").and_then(Value::as_str) {
                builder = builder.field("encoding", json!(encoding));
            }
            if let Some(content_kind) = metadata.get("content_kind").and_then(Value::as_str) {
                builder = builder.field("content_kind", json!(content_kind));
                if matches!(content_kind, "binary" | "image") {
                    builder = builder.field("binary", json!(true));
                }
            }
            if let Some(mime_type) = metadata.get("mime_type").and_then(Value::as_str) {
                builder = builder.field("mime_type", json!(mime_type));
            }

            // Add paging information
            if use_paging {
                if let Some(offset_bytes) = input.offset_bytes {
                    builder = builder.field("offset_bytes", json!(offset_bytes));
                }
                if let Some(page_size_bytes) = input.page_size_bytes {
                    builder = builder.field("page_size_bytes", json!(page_size_bytes));
                }
                if let Some(offset_lines) = input.offset_lines {
                    builder = builder.field("offset_lines", json!(offset_lines));
                }
                if let Some(page_size_lines) = input.page_size_lines {
                    builder = builder.field("page_size_lines", json!(page_size_lines));
                }
                if truncated {
                    builder = builder.field("truncated", json!(true));
                    builder = builder.field("truncation_reason", json!("reached_end_of_file"));
                }
            }

            let content_kind = metadata
                .get("content_kind")
                .and_then(Value::as_str)
                .unwrap_or("text");
            let full_legacy_text_read = !use_paging && !truncated && content_kind == "text";
            let response = builder.build_json();
            if full_legacy_text_read {
                self.track_exact_text_snapshot(&canonical, &content, size_bytes)
                    .await;
            } else {
                self.track_read_snapshot(&canonical).await;
            }
            if let Some(key) = cache_key.as_ref() {
                FILE_CACHE.put_file(key.clone(), response.clone()).await;
            }

            return Ok(response);
        }

        if let Some(spool_path) = missing_spool_candidate {
            if let Some(session_id) = pty_session_id_from_tool_output_path(&spool_path) {
                return Err(anyhow!(
                    "Error: Session output file not found: {}. This looks like a command session id. Use unified_exec with session_id=\"{}\" instead of read_file.",
                    self.workspace_relative_display(&spool_path),
                    session_id,
                ));
            }
            return Err(anyhow!(
                "Error: Spool file not found (possibly expired): {}. Re-run the original tool command to regenerate this output.",
                self.workspace_relative_display(&spool_path),
            ));
        }

        if let Some(directory_path) = directory_candidate {
            let display_path = self.workspace_relative_display(&directory_path);
            return Err(anyhow!(
                "Error: Path '{}' is a directory, not a file. Use unified_search with action=\"list\" and path=\"{}\" to inspect it, or set mode=\"recursive\" for nested discovery.",
                display_path,
                display_path,
            ));
        }

        Err(anyhow!(
            "Error: File not found: {}. Tried paths: {}.{}",
            path_str,
            potential_paths
                .iter()
                .map(|p| self.workspace_relative_display(p))
                .collect::<Vec<_>>()
                .join(", "),
            self.missing_path_suggestion_suffix(path_str, PathSuggestionKind::File)
        ))
    }
}

#[cfg(test)]
mod read_tests {
    use super::*;
    use crate::tools::grep_file::GrepSearchManager;
    use std::fs;
    use std::path::Path;
    use tempfile::TempDir;

    #[test]
    fn history_jsonl_detection() {
        assert!(is_history_jsonl(Path::new(
            "/tmp/.vtcode/history/test.jsonl"
        )));
        assert!(!is_history_jsonl(Path::new(
            "/tmp/.vtcode/history/test.txt"
        )));
        assert!(!is_history_jsonl(Path::new("/tmp/history/test.jsonl")));
    }

    #[test]
    fn tool_output_spool_path_detection() {
        assert!(is_tool_output_spool_path(Path::new(
            ".vtcode/context/tool_outputs/run-123.txt"
        )));
        assert!(is_tool_output_spool_path(Path::new(
            "/tmp/work/.vtcode/context/tool_outputs/run-123.txt"
        )));
        assert!(!is_tool_output_spool_path(Path::new(
            ".vtcode/history/session.jsonl"
        )));
    }

    #[test]
    fn pty_session_id_detection_from_tool_output_path() {
        assert_eq!(
            pty_session_id_from_tool_output_path(Path::new(
                ".vtcode/context/tool_outputs/run-658ceef2.txt"
            )),
            Some("run-658ceef2".to_string())
        );
        assert_eq!(
            pty_session_id_from_tool_output_path(Path::new(
                ".vtcode/context/tool_outputs/unified_exec_123.txt"
            )),
            None
        );
    }

    #[test]
    fn build_read_handler_args_normalizes_zero_bounds() {
        let canonical = Path::new("/tmp/example.txt");
        let args = json!({
            "path": "example.txt",
            "offset": 0,
            "limit": 0
        });

        let built = build_read_handler_args(&args, canonical);

        assert_eq!(built["file_path"], json!("/tmp/example.txt"));
        assert_eq!(built["offset"], json!(1));
        assert!(built.get("limit").is_none());
    }

    #[test]
    fn build_read_handler_args_avoids_duplicate_path_aliases() {
        let canonical = Path::new("/tmp/example.txt");
        let args = json!({
            "path": "example.txt"
        });

        let built = build_read_handler_args(&args, canonical);
        let parsed: ReadFileArgs = serde_json::from_value(built).unwrap();

        assert_eq!(parsed.file_path, "/tmp/example.txt");
        assert_eq!(parsed.offset, 1);
    }

    #[test]
    fn build_read_handler_args_normalizes_indentation() {
        let canonical = Path::new("/tmp/example.txt");

        let args_true = json!({
            "path": "example.txt",
            "indentation": true
        });
        let built_true = build_read_handler_args(&args_true, canonical);
        assert_eq!(built_true["mode"], json!("indentation"));
        assert_eq!(built_true["indentation"], json!({}));

        let args_false = json!({
            "path": "example.txt",
            "indentation": false,
            "mode": "slice"
        });
        let built_false = build_read_handler_args(&args_false, canonical);
        assert!(built_false.get("indentation").is_none());
        assert_eq!(built_false["mode"], json!("slice"));

        let args_false_indent_mode = json!({
            "path": "example.txt",
            "indentation": false,
            "mode": "indentation"
        });
        let built_false_indent_mode = build_read_handler_args(&args_false_indent_mode, canonical);
        assert!(built_false_indent_mode.get("indentation").is_none());
        assert_eq!(built_false_indent_mode["mode"], json!("slice"));

        let args_obj = json!({
            "path": "example.txt",
            "indentation": { "max_levels": 2 }
        });
        let built_obj = build_read_handler_args(&args_obj, canonical);
        assert_eq!(built_obj["mode"], json!("indentation"));
        assert_eq!(built_obj["indentation"]["max_levels"], json!(2));

        let args_true_str = json!({
            "path": "example.txt",
            "indentation": "true"
        });
        let built_true_str = build_read_handler_args(&args_true_str, canonical);
        assert_eq!(built_true_str["mode"], json!("indentation"));
        assert_eq!(built_true_str["indentation"], json!({}));

        let args_false_str = json!({
            "path": "example.txt",
            "indentation": "false"
        });
        let built_false_str = build_read_handler_args(&args_false_str, canonical);
        assert!(built_false_str.get("indentation").is_none());
    }

    #[test]
    fn build_read_handler_args_normalizes_mode() {
        let canonical = Path::new("/tmp/example.txt");

        let args_empty = json!({
            "path": "example.txt",
            "mode": ""
        });
        let built_empty = build_read_handler_args(&args_empty, canonical);
        assert_eq!(built_empty["mode"], json!("slice"));

        let args_whitespace = json!({
            "path": "example.txt",
            "mode": "   "
        });
        let built_whitespace = build_read_handler_args(&args_whitespace, canonical);
        assert_eq!(built_whitespace["mode"], json!("slice"));

        let args_unknown = json!({
            "path": "example.txt",
            "mode": "unknown"
        });
        let built_unknown = build_read_handler_args(&args_unknown, canonical);
        assert_eq!(built_unknown["mode"], json!("slice"));

        let args_indent = json!({
            "path": "example.txt",
            "mode": "Indentation"
        });
        let built_indent = build_read_handler_args(&args_indent, canonical);
        assert_eq!(built_indent["mode"], json!("indentation"));
    }

    #[test]
    fn history_cache_key_varies_with_offset() {
        let temp_dir = TempDir::new().unwrap();
        let history_dir = temp_dir.path().join(".vtcode/history");
        fs::create_dir_all(&history_dir).unwrap();
        let file_path = history_dir.join("session_0001.jsonl");
        fs::write(&file_path, "line1\nline2\n").unwrap();

        let metadata = fs::metadata(&file_path).unwrap();
        let key_a = build_history_cache_key(
            &file_path,
            &metadata,
            &json!({"offset_lines": 0, "page_size_lines": 1}),
        )
        .unwrap();
        let key_b = build_history_cache_key(
            &file_path,
            &metadata,
            &json!({"offset_lines": 1, "page_size_lines": 1}),
        )
        .unwrap();

        assert_ne!(key_a, key_b);
    }

    #[tokio::test]
    async fn test_read_file_paging_lines() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("test_file.txt");

        // Create test content with 10 lines
        let test_content =
            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
        fs::write(&test_file, test_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Test basic paging functionality: offset_lines=2, page_size_lines=3
        // Should return lines 3, 4, 5 (0-indexed: 2, 3, 4)
        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "offset_lines": 2,
            "page_size_lines": 3
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        assert_eq!(result["content"].as_str().unwrap(), "line3\nline4\nline5");
    }

    #[tokio::test]
    async fn test_read_file_paging_bytes() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("test_file.txt");

        let test_content = "line1\nline2\nline3\nline4\nline5\n";
        fs::write(&test_file, test_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Test byte-based paging: skip first 6 bytes ("line1\n") and read next 6 bytes
        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "offset_bytes": 6,
            "page_size_bytes": 6
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        assert_eq!(result["content"].as_str().unwrap(), "line2\n");
    }

    #[tokio::test]
    async fn test_read_file_offset_beyond_size() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("test_file.txt");

        let test_content = "line1\nline2\nline3\n";
        fs::write(&test_file, test_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Test when offset is beyond file size
        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "offset_lines": 100,
            "page_size_lines": 10
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        assert_eq!(result["content"].as_str().unwrap(), "");
    }

    #[tokio::test]
    async fn test_read_file_empty_file() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("empty_file.txt");

        fs::write(&test_file, "").unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Test reading empty file with paging
        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "offset_lines": 0,
            "page_size_lines": 10
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        assert_eq!(result["content"].as_str().unwrap(), "");
    }

    #[tokio::test]
    async fn test_read_file_legacy_functionality() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("test_file.txt");

        let test_content = "line1\nline2\nline3\nline4\nline5\n";
        fs::write(&test_file, test_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Test legacy functionality with max_bytes
        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "max_bytes": 10
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        let content = result["content"].as_str().unwrap();
        assert!(content.len() <= 10);
        assert!(content.starts_with("line1"));
    }

    #[tokio::test]
    async fn test_read_file_full_text_preserves_trailing_newline() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("note.txt");
        fs::write(&test_file, "hello\n").unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let result = file_ops
            .read_file(json!({ "path": "note.txt" }))
            .await
            .unwrap();

        assert_eq!(result["content"].as_str(), Some("hello\n"));
    }

    #[tokio::test]
    async fn test_read_file_legacy_token_chunking() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let test_file = workspace_root.join("test_file.txt");

        // Create test content with 50 lines
        let test_content = (1..=50)
            .map(|i| format!("line-{}", i))
            .collect::<Vec<_>>()
            .join("\n")
            + "\n";
        fs::write(&test_file, test_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        // Token budget small enough to keep roughly first+last 5-10 lines
        let max_tokens = 15 * 12; // ~12 lines worth using TOKENS_PER_LINE

        let args = json!({
            "path": test_file.to_string_lossy().into_owned(),
            "max_tokens": max_tokens
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert!(result["success"].as_bool().unwrap());
        let content = result["content"].as_str().unwrap();
        // Should contain first and last lines
        assert!(content.contains("line-1"));
        assert!(content.contains("line-50"));
        // Should indicate truncation
        assert!(result["is_truncated"].as_bool().unwrap());
        // Should report applied token budget
        assert_eq!(
            result["metadata"]["data"]["applied_max_tokens"]
                .as_u64()
                .unwrap(),
            max_tokens as u64
        );
    }

    #[tokio::test]
    async fn test_read_file_patch_payload_returns_actionable_error() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "input": "*** Begin Patch\n*** End Patch\n"
        });
        let err = file_ops.read_file(args).await.unwrap_err().to_string();

        assert!(err.contains("Patch content was sent to read_file"));
        assert!(err.contains("\"action\":\"patch\""));
    }

    #[tokio::test]
    async fn test_missing_spool_file_returns_actionable_error() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": ".vtcode/context/tool_outputs/unified_exec_123.txt"
        });
        let err = file_ops.read_file(args).await.unwrap_err().to_string();

        assert!(err.contains("Spool file not found"));
        assert!(err.contains("Re-run the original tool command"));
    }

    #[tokio::test]
    async fn test_missing_run_session_file_suggests_unified_exec() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": ".vtcode/context/tool_outputs/run-123abc.txt"
        });
        let err = file_ops.read_file(args).await.unwrap_err().to_string();

        assert!(err.contains("Session output file not found"));
        assert!(err.contains("unified_exec"));
        assert!(err.contains("run-123abc"));
    }

    #[tokio::test]
    async fn test_missing_file_suggests_similar_workspace_file() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        fs::create_dir_all(workspace_root.join("src")).unwrap();
        fs::write(workspace_root.join("src/tool_exec.rs"), "fn main() {}\n").unwrap();
        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": "src/tool_exe.rs"
        });
        let err = file_ops.read_file(args).await.unwrap_err().to_string();

        assert!(err.contains("Did you mean"));
        assert!(err.contains("src/tool_exec.rs"));
    }

    #[tokio::test]
    async fn test_directory_path_returns_actionable_list_guidance() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        fs::create_dir_all(workspace_root.join("src/agent")).unwrap();
        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": "src"
        });
        let err = file_ops.read_file(args).await.unwrap_err().to_string();

        assert!(err.contains("is a directory, not a file"));
        assert!(err.contains("action=\"list\""));
        assert!(err.contains("path=\"src\""));
    }

    #[tokio::test]
    async fn test_spool_file_reads_are_chunked_with_next_offset() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
        fs::create_dir_all(&spool_dir).unwrap();
        let spool_file = spool_dir.join("unified_exec_123.txt");
        let spool_content = (1..=120)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(&spool_file, spool_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": ".vtcode/context/tool_outputs/unified_exec_123.txt"
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert_eq!(result["success"], true);
        assert_eq!(result["spool_chunked"], true);
        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
        assert_eq!(result["has_more"], true);
        assert_eq!(
            result["next_read_args"],
            json!({
                "path": ".vtcode/context/tool_outputs/unified_exec_123.txt",
                "offset": SPOOL_CHUNK_DEFAULT_LIMIT_LINES + 1,
                "limit": SPOOL_CHUNK_DEFAULT_LIMIT_LINES
            })
        );
        assert!(result.get("chunk_limit").is_none());
        assert!(result.get("next_offset").is_none());
        assert!(result.get("preferred_next_action").is_none());
        assert!(result.get("follow_up_prompt").is_none());
    }

    #[tokio::test]
    async fn test_spool_file_last_chunk_omits_continuation_fields() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
        fs::create_dir_all(&spool_dir).unwrap();
        let spool_file = spool_dir.join("unified_exec_999.txt");
        let spool_content = (1..=5)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(&spool_file, spool_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let args = json!({
            "path": ".vtcode/context/tool_outputs/unified_exec_999.txt"
        });

        let result = file_ops.read_file(args).await.unwrap();
        assert_eq!(result["success"], true);
        assert_eq!(result["spool_chunked"], true);
        assert_eq!(result["lines_returned"], 5);
        assert!(result.get("has_more").is_none());
        assert!(result.get("next_read_args").is_none());
        assert!(result.get("follow_up_prompt").is_none());
        assert!(result.get("chunk_limit").is_none());
        assert!(result.get("next_offset").is_none());
        assert!(result.get("preferred_next_action").is_none());
    }

    #[tokio::test]
    async fn test_spool_file_exact_limit_at_eof_omits_continuation_fields() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
        fs::create_dir_all(&spool_dir).unwrap();
        let spool_file = spool_dir.join("unified_exec_exact.txt");
        let spool_content = (1..=SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(&spool_file, spool_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let result = file_ops
            .read_file(json!({
                "path": ".vtcode/context/tool_outputs/unified_exec_exact.txt"
            }))
            .await
            .unwrap();

        assert_eq!(result["success"], true);
        assert_eq!(result["spool_chunked"], true);
        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
        assert!(result.get("has_more").is_none());
        assert!(result.get("next_read_args").is_none());
    }

    #[tokio::test]
    async fn test_read_file_accepts_compact_spool_continuation_args() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_root = temp_dir.path().to_path_buf();
        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
        fs::create_dir_all(&spool_dir).unwrap();
        let spool_file = spool_dir.join("unified_exec_456.txt");
        let spool_content = (1..=120)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(&spool_file, spool_content).unwrap();

        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
        let file_ops = FileOpsTool::new(workspace_root, grep_manager);

        let result = file_ops
            .read_file(json!({
                "p": ".vtcode/context/tool_outputs/unified_exec_456.txt",
                "o": 81,
                "l": 40
            }))
            .await
            .unwrap();

        assert_eq!(result["success"], true);
        assert_eq!(
            result["path"],
            ".vtcode/context/tool_outputs/unified_exec_456.txt"
        );
        assert_eq!(result["spool_chunked"], true);
        assert_eq!(result["lines_returned"], 40);
        assert_eq!(
            result["content"].as_str().unwrap().lines().next(),
            Some("line81")
        );
        assert!(result.get("has_more").is_none());
        assert!(result.get("next_read_args").is_none());
    }
}
