use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;

use chrono::{TimeZone, Utc};

use crate::error::{Result, TuicrError};
use crate::model::{DiffFile, DiffLine, FileStatus};
use crate::syntax::SyntaxHighlighter;
use crate::vcs::diff_parser::{self, DiffFormat};
use crate::vcs::traits::{
    CommitInfo, DiffWhitespaceMode, ResolvedRevisionRange, RevisionDiffTarget, VcsBackend, VcsInfo,
    VcsType,
};
use crate::vcs::{
    BATCH_BOUNDARY, apply_container_full_file_highlight, parse_batched_files, slice_context_lines,
};

/// Parse an hg description into (summary, optional body).
fn parse_hg_description(desc: &str) -> (String, Option<String>) {
    let mut lines = desc.lines();
    let summary = lines.next().unwrap_or("(no message)").to_string();
    let body_text: String = lines
        .skip_while(|l| l.trim().is_empty())
        .collect::<Vec<_>>()
        .join("\n");
    let body = if body_text.trim().is_empty() {
        None
    } else {
        Some(body_text)
    };
    (summary, body)
}

/// Mercurial backend implementation using hg CLI commands
pub struct HgBackend {
    info: VcsInfo,
    whitespace_mode: DiffWhitespaceMode,
}

impl HgBackend {
    /// Discover a Mercurial repository from the current directory
    pub fn discover(whitespace_mode: DiffWhitespaceMode) -> Result<Self> {
        // Use `hg root` to find the repository root
        // This handles being called from subdirectories
        let root_output = Command::new("hg")
            .args(["root"])
            .output()
            .map_err(|e| TuicrError::VcsCommand(format!("Failed to run hg: {}", e)))?;

        if !root_output.status.success() {
            return Err(TuicrError::NotARepository);
        }

        let root_path = PathBuf::from(String::from_utf8_lossy(&root_output.stdout).trim());

        Self::from_path(root_path, whitespace_mode)
    }

    /// Create backend from a known path (used by discover and tests)
    fn from_path(root_path: PathBuf, whitespace_mode: DiffWhitespaceMode) -> Result<Self> {
        // Canonicalize to resolve symlinks (e.g., /var -> /private/var on macOS)
        let root_path = root_path.canonicalize().unwrap_or(root_path);

        // Get current revision info
        let head_commit = run_hg_command(&root_path, ["id", "-i"])
            .map(|s| s.trim().trim_end_matches('+').to_string())
            .unwrap_or_else(|_| "unknown".to_string());

        let branch_name = run_hg_command(&root_path, ["branch"])
            .ok()
            .map(|s| s.trim().to_string());

        let info = VcsInfo {
            root_path,
            head_commit,
            branch_name,
            vcs_type: VcsType::Mercurial,
        };

        Ok(Self {
            info,
            whitespace_mode,
        })
    }

    fn diff_args<'a>(&self, args: &'a [&'a str]) -> Cow<'a, [&'a str]> {
        if !self.whitespace_mode.ignores_all() {
            return Cow::Borrowed(args);
        }

        let mut args_with_whitespace = Vec::with_capacity(args.len() + 1);
        args_with_whitespace.push(args[0]);
        args_with_whitespace.push("--ignore-all-space");
        args_with_whitespace.extend_from_slice(&args[1..]);
        Cow::Owned(args_with_whitespace)
    }
}

impl VcsBackend for HgBackend {
    fn info(&self) -> &VcsInfo {
        &self.info
    }

    fn get_working_tree_diff(&self, highlighter: &SyntaxHighlighter) -> Result<Vec<DiffFile>> {
        let args = self.diff_args(&["diff"]);
        let diff_output = run_hg_command(&self.info.root_path, args.iter().copied())?;

        if diff_output.trim().is_empty() {
            return Err(TuicrError::NoChanges);
        }

        let mut files = diff_parser::parse_unified_diff(&diff_output, DiffFormat::Hg, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            ".",
            None,
            &mut files,
            highlighter,
            hg_cat_batch,
        )?;
        Ok(files)
    }

    fn fetch_context_lines(
        &self,
        file_path: &Path,
        file_status: FileStatus,
        ref_commit: Option<&str>,
        start_line: u32,
        end_line: u32,
    ) -> Result<Vec<DiffLine>> {
        if start_line > end_line || start_line == 0 {
            return Ok(Vec::new());
        }

        let path_str = file_path.to_string_lossy();
        let content = if let Some(commit) = ref_commit {
            run_hg_command(&self.info.root_path, ["cat", "-r", commit, &path_str])?
        } else if file_status == FileStatus::Deleted {
            run_hg_command(&self.info.root_path, ["cat", "-r", ".", &path_str])?
        } else {
            std::fs::read_to_string(self.info.root_path.join(file_path))?
        };

        Ok(slice_context_lines(&content, start_line, end_line))
    }

    fn file_line_count(
        &self,
        file_path: &Path,
        file_status: FileStatus,
        ref_commit: Option<&str>,
    ) -> Result<u32> {
        let path_str = file_path.to_string_lossy();
        let content = if let Some(commit) = ref_commit {
            run_hg_command(&self.info.root_path, ["cat", "-r", commit, &path_str])?
        } else if file_status == FileStatus::Deleted {
            run_hg_command(&self.info.root_path, ["cat", "-r", ".", &path_str])?
        } else {
            std::fs::read_to_string(self.info.root_path.join(file_path))?
        };
        Ok(content.lines().count() as u32)
    }

    fn resolve_revision_range(&self, revisions: &str) -> Result<ResolvedRevisionRange<'static>> {
        // Use hg log to resolve the revset to commit hashes.
        // hg log outputs newest first; we reverse so oldest is first.
        let output = run_hg_command(
            &self.info.root_path,
            ["log", "-r", revisions, "--template", "{node}\\n"],
        )?;

        let mut commit_ids: Vec<String> = output
            .lines()
            .map(|l| l.trim())
            .filter(|l| !l.is_empty())
            .map(|l| l.to_string())
            .collect();

        if commit_ids.is_empty() {
            return Err(TuicrError::NoChanges);
        }

        // hg log outputs newest first; reverse so oldest is first
        commit_ids.reverse();
        Ok(ResolvedRevisionRange::from_owned_commit_ids(
            commit_ids,
            RevisionDiffTarget::CommitList,
        ))
    }

    fn get_recent_commits(&self, offset: usize, limit: usize) -> Result<Vec<CommitInfo>> {
        // Use hg log with a template to get structured output
        // Template fields separated by \x00, records separated by \x01
        //
        // hg log doesn't have a --skip option, so we fetch offset+limit commits
        // and skip the first `offset` in Rust code
        let fetch_count = offset + limit;
        let template =
            "{node}\\x00{node|short}\\x00{desc}\\x00{author|user}\\x00{date|hgdate}\\x01";
        let output = run_hg_command(
            &self.info.root_path,
            [
                "log",
                "-l",
                &fetch_count.to_string(),
                "--template",
                template,
            ],
        )?;

        let mut commits = Vec::new();
        for record in output.split('\x01') {
            let record = record.trim();
            if record.is_empty() {
                continue;
            }

            let parts: Vec<&str> = record.split('\x00').collect();
            if parts.len() < 5 {
                continue;
            }

            let id = parts[0].to_string();
            let short_id = parts[1].to_string();
            let (summary, body) = parse_hg_description(parts[2]);
            let author = parts[3].to_string();

            // hgdate format is "unix_timestamp timezone_offset"
            let time = parts[4]
                .split_whitespace()
                .next()
                .and_then(|s| s.parse::<i64>().ok())
                .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
                .unwrap_or_else(Utc::now);

            commits.push(CommitInfo {
                id,
                short_id,
                branch_name: None,
                summary,
                body,
                author,
                time,
            });
        }

        Ok(commits.into_iter().skip(offset).collect())
    }

    fn get_commit_range_diff(
        &self,
        revision_range: &ResolvedRevisionRange<'_>,
        highlighter: &SyntaxHighlighter,
    ) -> Result<Vec<DiffFile>> {
        let commit_ids = &revision_range.commit_ids;
        if commit_ids.is_empty() {
            return Err(TuicrError::NoChanges);
        }

        // commit_ids are ordered from oldest to newest
        //
        // Note on Sapling/Mercurial compatibility:
        // - Sapling (Meta's hg fork) has issues with full 40-char hashes in certain operations
        // - We use 12-char short hashes which work with both standard Mercurial and Sapling
        // - The parents() revset is used to find the parent commit for diffing
        let oldest = &commit_ids[0];
        let oldest_short = if oldest.len() > 12 {
            &oldest[..12]
        } else {
            oldest.as_str()
        };

        let newest = commit_ids.last().unwrap();
        let newest_short = if newest.len() > 12 {
            &newest[..12]
        } else {
            newest.as_str()
        };

        // First, get the parent commit of the oldest
        // We use "log -r 'parents({oldest})'" to get the parent hash
        let parent_output = run_hg_command(
            &self.info.root_path,
            [
                "log",
                "-r",
                &format!("parents({})", oldest_short),
                "--template",
                "{node|short}",
            ],
        );

        // If there's no parent (first commit), diff from null
        let from_rev = match parent_output {
            Ok(parent) if !parent.trim().is_empty() => parent.trim().to_string(),
            _ => "null".to_string(),
        };

        let diff_args = ["diff", "-r", &from_rev, "-r", newest_short];
        let args = self.diff_args(&diff_args);
        let diff_output = run_hg_command(&self.info.root_path, args.iter().copied())?;

        if diff_output.trim().is_empty() {
            return Err(TuicrError::NoChanges);
        }

        let mut files = diff_parser::parse_unified_diff(&diff_output, DiffFormat::Hg, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            &from_rev,
            Some(newest_short),
            &mut files,
            highlighter,
            hg_cat_batch,
        )?;
        Ok(files)
    }

    fn get_commits_info(&self, ids: &[String]) -> Result<Vec<CommitInfo>> {
        if ids.is_empty() {
            return Ok(Vec::new());
        }
        // Use hg log with a revset matching the given IDs
        let revset = ids
            .iter()
            .map(|id| {
                if id.len() > 12 {
                    &id[..12]
                } else {
                    id.as_str()
                }
            })
            .collect::<Vec<_>>()
            .join(" | ");
        let template =
            "{node}\\x00{node|short}\\x00{desc}\\x00{author|user}\\x00{date|hgdate}\\x01";
        let output = run_hg_command(
            &self.info.root_path,
            ["log", "-r", &revset, "--template", template],
        )?;

        let mut by_id: HashMap<String, CommitInfo> = HashMap::new();
        for record in output.split('\x01') {
            let record = record.trim();
            if record.is_empty() {
                continue;
            }
            let parts: Vec<&str> = record.split('\x00').collect();
            if parts.len() < 5 {
                continue;
            }
            let id = parts[0].to_string();
            let short_id = parts[1].to_string();
            let (summary, body) = parse_hg_description(parts[2]);
            let author = parts[3].to_string();
            let time = parts[4]
                .split_whitespace()
                .next()
                .and_then(|s| s.parse::<i64>().ok())
                .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
                .unwrap_or_else(Utc::now);
            by_id.insert(
                id.clone(),
                CommitInfo {
                    id,
                    short_id,
                    branch_name: None,
                    summary,
                    body,
                    author,
                    time,
                },
            );
        }

        // Return in input order
        Ok(ids.iter().filter_map(|id| by_id.remove(id)).collect())
    }

    fn get_working_tree_with_commits_diff(
        &self,
        commit_ids: &[String],
        highlighter: &SyntaxHighlighter,
    ) -> Result<Vec<DiffFile>> {
        if commit_ids.is_empty() {
            return Err(TuicrError::NoChanges);
        }

        // commit_ids are ordered from oldest to newest
        let oldest = &commit_ids[0];
        let oldest_short = if oldest.len() > 12 {
            &oldest[..12]
        } else {
            oldest.as_str()
        };

        // Get the parent of the oldest commit
        let parent_output = run_hg_command(
            &self.info.root_path,
            [
                "log",
                "-r",
                &format!("parents({})", oldest_short),
                "--template",
                "{node|short}",
            ],
        );

        let from_rev = match parent_output {
            Ok(parent) if !parent.trim().is_empty() => parent.trim().to_string(),
            _ => "null".to_string(),
        };

        let diff_args = ["diff", "-r", &from_rev];
        let args = self.diff_args(&diff_args);
        let diff_output = run_hg_command(&self.info.root_path, args.iter().copied())?;

        if diff_output.trim().is_empty() {
            return Err(TuicrError::NoChanges);
        }

        let mut files = diff_parser::parse_unified_diff(&diff_output, DiffFormat::Hg, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            &from_rev,
            None,
            &mut files,
            highlighter,
            hg_cat_batch,
        )?;
        Ok(files)
    }
}

/// Fetch the full content of `paths` at `rev` in a single `hg cat` subprocess.
///
/// hg cat is dominated by Python startup (~280 ms) regardless of file count,
/// so batching every container file into one call is significantly faster than
/// fetching each one separately.
fn hg_cat_batch(root: &Path, rev: &str, paths: &[PathBuf]) -> Result<HashMap<PathBuf, String>> {
    if paths.is_empty() {
        return Ok(HashMap::new());
    }
    let template = format!("\n{BATCH_BOUNDARY}\n{{path}}\n{{data}}");
    let path_strs: Vec<String> = paths
        .iter()
        .map(|p| p.to_string_lossy().into_owned())
        .collect();
    let mut args: Vec<&str> = vec!["cat", "-r", rev, "--template", &template];
    args.extend(path_strs.iter().map(String::as_str));
    let output = run_hg_command(root, &args)?;
    Ok(parse_batched_files(&output))
}

/// Run an hg command and return its stdout.
fn run_hg_command<I, S>(root: &Path, args: I) -> Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<std::ffi::OsStr>,
{
    let args: Vec<S> = args.into_iter().collect();
    let output = Command::new("hg")
        .current_dir(root)
        .args(args.iter().map(|arg| arg.as_ref()))
        .output()
        .map_err(|e| TuicrError::VcsCommand(format!("Failed to run hg: {}", e)))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let rendered_args = args
            .iter()
            .map(|arg| arg.as_ref().to_string_lossy())
            .collect::<Vec<_>>()
            .join(" ");
        return Err(TuicrError::VcsCommand(format!(
            "hg {} failed: {}",
            rendered_args, stderr
        )));
    }

    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::LineOrigin;
    use crate::vcs::RevisionDiffTarget;
    use std::fs;

    /// Check if hg command is available
    fn hg_available() -> bool {
        Command::new("hg")
            .arg("--version")
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    }

    /// Discover a Mercurial repository from a specific directory
    fn discover_in(path: &Path) -> Result<HgBackend> {
        let root_output = Command::new("hg")
            .args(["root"])
            .current_dir(path)
            .output()
            .map_err(|e| TuicrError::VcsCommand(format!("Failed to run hg: {}", e)))?;

        if !root_output.status.success() {
            return Err(TuicrError::NotARepository);
        }

        let root_path = PathBuf::from(String::from_utf8_lossy(&root_output.stdout).trim());

        HgBackend::from_path(root_path, DiffWhitespaceMode::Normal)
    }

    /// Create a temporary hg repo for testing.
    /// Returns None if hg is not available.
    fn setup_test_repo() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        // Initialize hg repo
        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        // Create initial file
        fs::write(root.join("hello.txt"), "hello world\n").expect("Failed to write file");

        // Add and commit
        Command::new("hg")
            .args(["add", "hello.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");

        Command::new("hg")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Make a modification
        fs::write(root.join("hello.txt"), "hello world\nmodified line\n")
            .expect("Failed to modify file");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_discover() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        // Use discover_in to avoid set_current_dir race conditions
        let backend = discover_in(temp.path()).expect("Failed to discover hg repo");
        let info = backend.info();

        // Canonicalize temp path to handle macOS /var -> /private/var symlink
        let expected_path = temp.path().canonicalize().unwrap();
        assert_eq!(info.root_path, expected_path);
        assert_eq!(info.vcs_type, VcsType::Mercurial);
        assert!(!info.head_commit.is_empty());
    }

    #[test]
    fn test_hg_working_tree_diff() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        // Use from_path directly to avoid set_current_dir race conditions
        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        // Canonicalize temp path to handle macOS /var -> /private/var symlink
        let expected_path = temp.path().canonicalize().unwrap();
        assert_eq!(backend.info().root_path, expected_path);
        assert_eq!(backend.info().vcs_type, VcsType::Mercurial);

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");

        assert_eq!(files.len(), 1);
        assert_eq!(
            files[0].new_path.as_ref().unwrap().to_str().unwrap(),
            "hello.txt"
        );
        assert_eq!(files[0].status, FileStatus::Modified);
    }

    #[test]
    fn test_hg_diff_ignores_all_whitespace_when_configured() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        fs::write(temp.path().join("hello.txt"), " hello world \n")
            .expect("Failed to write whitespace-only edit");
        let backend =
            HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::IgnoreAll)
                .expect("Failed to create hg backend");

        assert!(matches!(
            backend.get_working_tree_diff(&SyntaxHighlighter::default()),
            Err(TuicrError::NoChanges)
        ));

        fs::write(temp.path().join("hello.txt"), " hello ship \n")
            .expect("Failed to write non-whitespace edit");
        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("non-whitespace edit should still produce a diff");
        assert_eq!(files.len(), 1);
    }

    #[test]
    fn test_hg_working_tree_with_commits_ignores_all_whitespace_when_configured() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        fs::write(temp.path().join("hello.txt"), " hello world \n")
            .expect("Failed to write whitespace-only edit");
        let output = Command::new("hg")
            .args(["commit", "-m", "Whitespace commit"])
            .current_dir(temp.path())
            .output()
            .expect("Failed to commit whitespace edit");
        assert!(
            output.status.success(),
            "hg commit failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );

        let backend =
            HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::IgnoreAll)
                .expect("Failed to create hg backend");
        let commits = backend
            .get_recent_commits(0, 5)
            .expect("Failed to get commits");
        let whitespace_commit = commits
            .iter()
            .find(|commit| commit.summary == "Whitespace commit")
            .expect("Expected whitespace commit");

        assert!(matches!(
            backend.get_working_tree_with_commits_diff(
                std::slice::from_ref(&whitespace_commit.id),
                &SyntaxHighlighter::default()
            ),
            Err(TuicrError::NoChanges)
        ));

        fs::write(temp.path().join("hello.txt"), " hello ship \n")
            .expect("Failed to write non-whitespace edit");
        let files = backend
            .get_working_tree_with_commits_diff(
                std::slice::from_ref(&whitespace_commit.id),
                &SyntaxHighlighter::default(),
            )
            .expect("non-whitespace edit should still produce a diff");
        assert_eq!(files.len(), 1);
    }

    #[test]
    fn test_hg_fetch_context_lines() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        // Use from_path directly to avoid set_current_dir race conditions
        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        // Canonicalize temp path to handle macOS /var -> /private/var symlink
        let expected_path = temp.path().canonicalize().unwrap();
        assert_eq!(backend.info().root_path, expected_path);

        // Fetch context lines from working tree (modified file)
        let lines = backend
            .fetch_context_lines(Path::new("hello.txt"), FileStatus::Modified, None, 1, 2)
            .expect("Failed to fetch context lines");

        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0].content, "hello world");
        assert_eq!(lines[1].content, "modified line");
    }

    /// Create a test repo with multiple commits (no pending changes).
    /// Returns None if hg is not available.
    fn setup_test_repo_with_commits() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        // Initialize hg repo
        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        // First commit
        fs::write(root.join("file1.txt"), "first file\n").expect("Failed to write file");
        Command::new("hg")
            .args(["add", "file1.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");
        Command::new("hg")
            .args(["commit", "-m", "First commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Second commit
        fs::write(root.join("file2.txt"), "second file\n").expect("Failed to write file");
        Command::new("hg")
            .args(["add", "file2.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");
        Command::new("hg")
            .args(["commit", "-m", "Second commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Third commit - modify first file
        fs::write(root.join("file1.txt"), "first file\nmodified\n").expect("Failed to write file");
        Command::new("hg")
            .args(["commit", "-m", "Third commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_get_recent_commits() {
        let Some(temp) = setup_test_repo_with_commits() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let commits = backend
            .get_recent_commits(0, 5)
            .expect("Failed to get commits");

        assert_eq!(commits.len(), 3);
        // Most recent commit should be first
        assert_eq!(commits[0].summary, "Third commit");
        assert_eq!(commits[1].summary, "Second commit");
        assert_eq!(commits[2].summary, "First commit");

        // All commits should have valid ids
        for commit in &commits {
            assert!(!commit.id.is_empty());
            assert!(!commit.short_id.is_empty());
            assert!(commit.short_id.len() <= commit.id.len());
        }
    }

    #[test]
    fn test_hg_get_commit_range_diff() {
        let Some(temp) = setup_test_repo_with_commits() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let commits = backend
            .get_recent_commits(0, 5)
            .expect("Failed to get commits");
        assert_eq!(commits.len(), 3);

        // Get diff for the last two commits (Second and Third)
        let commit_ids = vec![commits[1].id.clone(), commits[0].id.clone()];
        let diff_result = backend.get_commit_range_diff(
            &ResolvedRevisionRange::from_owned_commit_ids(
                commit_ids,
                RevisionDiffTarget::CommitList,
            ),
            &SyntaxHighlighter::default(),
        );

        // Note: Sapling (Meta's hg fork) may fail with "id_dag_snapshot()" error
        // in certain temporary directory configurations. Skip the test in that case.
        let diff = match diff_result {
            Ok(d) => d,
            Err(TuicrError::VcsCommand(msg)) if msg.contains("id_dag_snapshot") => {
                eprintln!("Skipping test: Sapling-specific issue with tempdir repos");
                return;
            }
            Err(e) => panic!("Failed to get commit range diff: {:?}", e),
        };

        // Should have changes from both commits
        // Second commit added file2.txt, Third modified file1.txt
        assert!(!diff.is_empty());

        let file_paths: Vec<_> = diff
            .iter()
            .filter_map(|f| f.new_path.as_ref().map(|p| p.to_string_lossy().to_string()))
            .collect();

        // Both files should be in the diff
        assert!(
            file_paths.contains(&"file2.txt".to_string()),
            "Expected file2.txt in diff, got {:?}",
            file_paths
        );
        assert!(
            file_paths.contains(&"file1.txt".to_string()),
            "Expected file1.txt in diff, got {:?}",
            file_paths
        );
    }

    /// Create a test repo with a renamed file (no content changes).
    fn setup_test_repo_with_rename() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        // Initialize hg repo
        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        // Create and commit a file
        fs::write(root.join("original.txt"), "file content\n").expect("Failed to write file");
        Command::new("hg")
            .args(["add", "original.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");
        Command::new("hg")
            .args(["commit", "-m", "Add original file"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Rename the file using hg rename
        Command::new("hg")
            .args(["rename", "original.txt", "renamed.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to rename file");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_renamed_file_without_content_changes() {
        let Some(temp) = setup_test_repo_with_rename() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");

        // hg should show the rename
        assert!(!files.is_empty(), "Expected at least one file change");

        // Verify we can get display_path without panic (the bug we fixed)
        for file in &files {
            let _path = file.display_path();
        }

        // Look for the renamed file
        let renamed_file = files.iter().find(|f| {
            f.new_path
                .as_ref()
                .is_some_and(|p| p.to_str() == Some("renamed.txt"))
        });
        assert!(
            renamed_file.is_some(),
            "Expected to find renamed.txt in diff"
        );
    }

    /// Create a test repo with a copied file.
    fn setup_test_repo_with_copy() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        // Initialize hg repo
        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        // Create and commit a file
        fs::write(root.join("source.txt"), "source content\n").expect("Failed to write file");
        Command::new("hg")
            .args(["add", "source.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");
        Command::new("hg")
            .args(["commit", "-m", "Add source file"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Copy the file using hg copy
        Command::new("hg")
            .args(["copy", "source.txt", "dest.txt"])
            .current_dir(root)
            .output()
            .expect("Failed to copy file");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_copied_file() {
        let Some(temp) = setup_test_repo_with_copy() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");

        assert!(!files.is_empty(), "Expected at least one file change");

        // Verify we can get display_path without panic (the bug we fixed)
        for file in &files {
            let _path = file.display_path();
        }

        // Look for the copied file
        let copied_file = files.iter().find(|f| {
            f.new_path
                .as_ref()
                .is_some_and(|p| p.to_str() == Some("dest.txt"))
        });
        assert!(copied_file.is_some(), "Expected to find dest.txt in diff");
    }

    /// Create a test repo with a binary file.
    fn setup_test_repo_with_binary() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        // Initialize hg repo
        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        // Create a binary file (PNG header bytes)
        let png_header: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
        fs::write(root.join("image.png"), png_header).expect("Failed to write binary file");

        // Add the file
        Command::new("hg")
            .args(["add", "image.png"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_binary_file_added() {
        let Some(temp) = setup_test_repo_with_binary() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");

        assert_eq!(files.len(), 1, "Expected one file");

        let file = &files[0];
        // Verify we can get display_path without panic (the bug we fixed)
        // Don't assert exact path/status as hg implementations differ (Sapling vs standard hg)
        let _path = file.display_path();
    }

    /// Set up an hg repo with a committed Vue file ready to be edited.
    fn setup_test_repo_with_vue() -> Option<tempfile::TempDir> {
        if !hg_available() {
            return None;
        }

        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
        let root = temp_dir.path();

        Command::new("hg")
            .args(["init"])
            .current_dir(root)
            .output()
            .expect("Failed to init hg repo");

        let initial = "<template>\n  <div>{{ msg }}</div>\n</template>\n\n<script setup>\nimport { ref } from 'vue'\nconst msg = ref('hi')\nconst other = 1\n</script>\n";
        fs::write(root.join("App.vue"), initial).expect("Failed to write Vue file");

        Command::new("hg")
            .args(["add", "App.vue"])
            .current_dir(root)
            .output()
            .expect("Failed to add file");
        Command::new("hg")
            .args(["commit", "-m", "Add Vue file"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        let edited = "<template>\n  <div>{{ msg }}</div>\n</template>\n\n<script setup>\nimport { ref } from 'vue'\nconst msg = ref('hello')\nconst other = 1\n</script>\n";
        fs::write(root.join("App.vue"), edited).expect("Failed to modify Vue file");

        Some(temp_dir)
    }

    #[test]
    fn test_hg_highlights_vue_script_hunk_using_full_file_context() {
        let Some(temp) = setup_test_repo_with_vue() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");
        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");
        assert_eq!(files.len(), 1);

        let changed_lines: Vec<_> = files[0].hunks[0]
            .lines
            .iter()
            .filter(|l| matches!(l.origin, LineOrigin::Addition | LineOrigin::Deletion))
            .collect();
        assert!(!changed_lines.is_empty(), "expected change lines in hunk");

        for line in changed_lines {
            let spans = line
                .highlighted_spans
                .as_ref()
                .unwrap_or_else(|| panic!("vue line should be highlighted: {line:?}"));
            let unique_fgs: std::collections::HashSet<_> =
                spans.iter().filter_map(|(s, _)| s.fg).collect();
            assert!(
                unique_fgs.len() >= 2,
                "vue hunk line {line:?} should have varied fg colors, got {unique_fgs:?}"
            );
        }
    }

    #[test]
    fn test_hg_binary_file_deleted() {
        let Some(temp) = setup_test_repo_with_binary() else {
            eprintln!("Skipping test: hg command not available");
            return;
        };

        let root = temp.path();

        // Commit the binary file first
        Command::new("hg")
            .args(["commit", "-m", "Add binary file"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Delete the binary file using hg remove
        Command::new("hg")
            .args(["remove", "image.png"])
            .current_dir(root)
            .output()
            .expect("Failed to remove file");

        let backend = HgBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create hg backend");

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("Failed to get diff");

        assert_eq!(files.len(), 1, "Expected one file");

        let file = &files[0];
        // Verify we can get display_path without panic (the bug we fixed)
        // Don't assert exact path/status as hg implementations differ (Sapling vs standard hg)
        let _path = file.display_path();
    }
}
