//! Jujutsu (jj) backend implementation using CLI commands.

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

use chrono::{DateTime, 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 a jj description into (summary, optional body).
fn parse_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)
}

/// Jujutsu backend implementation using jj CLI commands
pub struct JjBackend {
    info: VcsInfo,
    whitespace_mode: DiffWhitespaceMode,
}

impl JjBackend {
    /// Discover a Jujutsu repository from the current directory
    pub fn discover(whitespace_mode: DiffWhitespaceMode) -> Result<Self> {
        // Use `jj root` to find the repository root
        // This handles being called from subdirectories
        let root_output = Command::new("jj")
            .args(["root", "--color=never"])
            .output()
            .map_err(|e| TuicrError::VcsCommand(format!("Failed to run jj: {}", 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 change id (jj uses change IDs rather than commit hashes)
        let head_commit = run_jj_command(
            &root_path,
            ["log", "-r", "@", "--no-graph", "-T", "change_id.short()"],
        )
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|_| "unknown".to_string());

        // jj doesn't have branches in the traditional sense, but we can show the bookmark if set
        // First check if @ has a bookmark directly, otherwise find the closest ancestor bookmark
        let branch_name = run_jj_command(
            &root_path,
            ["log", "-r", "@", "--no-graph", "-T", "bookmarks"],
        )
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .or_else(|| {
            // Find the closest bookmark in ancestors using heads(::@ & bookmarks())
            run_jj_command(
                &root_path,
                [
                    "log",
                    "-r",
                    "heads(::@ & bookmarks())",
                    "--no-graph",
                    "-T",
                    "bookmarks",
                    "--limit",
                    "1",
                ],
            )
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
        })
        // Extract first local bookmark (filter out remote tracking like "name@upstream")
        .map(|s| {
            s.split_whitespace()
                .find(|b| !b.contains('@'))
                .unwrap_or_else(|| s.split_whitespace().next().unwrap_or(&s))
                .to_string()
        });

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

        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 JjBackend {
    fn info(&self) -> &VcsInfo {
        &self.info
    }

    fn get_working_tree_diff(&self, highlighter: &SyntaxHighlighter) -> Result<Vec<DiffFile>> {
        let args = self.diff_args(&["diff", "--git"]);
        let diff_output = run_jj_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::GitStyle, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            "@-",
            None,
            &mut files,
            highlighter,
            jj_show_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_jj_command(
                &self.info.root_path,
                ["file", "show", "-r", commit, &path_str],
            )?
        } else if file_status == FileStatus::Deleted {
            run_jj_command(
                &self.info.root_path,
                ["file", "show", "-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_jj_command(
                &self.info.root_path,
                ["file", "show", "-r", commit, &path_str],
            )?
        } else if file_status == FileStatus::Deleted {
            run_jj_command(
                &self.info.root_path,
                ["file", "show", "-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 jj log to resolve the revisions to commit IDs, reverse-chronological by default.
        // We reverse the result so the oldest commit is first (matching get_commit_range_diff expectations).
        let output = run_jj_command(
            &self.info.root_path,
            [
                "log",
                "-r",
                revisions,
                "--no-graph",
                "-T",
                r#"commit_id ++ "\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);
        }

        // jj 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 jj log with a template to get structured output
        // Template fields separated by \x00, records separated by \x01
        // Note: jj uses change_id for identifying changes, commit_id for the underlying git commit
        //
        // jj 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 = r#"commit_id ++ "\x00" ++ commit_id.short() ++ "\x00" ++ description ++ "\x00" ++ author.email() ++ "\x00" ++ committer.timestamp() ++ "\x01""#;
        let output = run_jj_command(
            &self.info.root_path,
            [
                "log",
                "-r",
                "::@",
                "--limit",
                &fetch_count.to_string(),
                "--no-graph",
                "-T",
                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_description(parts[2]);
            let author = parts[3].to_string();

            // jj timestamp format is ISO 8601: "2024-01-15T10:30:00.000-05:00"
            let time = DateTime::parse_from_rfc3339(parts[4])
                .map(|dt| dt.with_timezone(&Utc))
                .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
        let oldest = &commit_ids[0];
        let newest = commit_ids.last().unwrap();

        // Get the parent of the oldest commit to include its changes
        // In jj, we use {commit}- to get the parent(s)
        let from_rev = format!("{}-", oldest);
        let diff_args = ["diff", "--from", &from_rev, "--to", newest, "--git"];
        let args = self.diff_args(&diff_args);
        let diff_output = run_jj_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::GitStyle, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            &from_rev,
            Some(newest),
            &mut files,
            highlighter,
            jj_show_batch,
        )?;
        Ok(files)
    }

    fn get_commits_info(&self, ids: &[String]) -> Result<Vec<CommitInfo>> {
        if ids.is_empty() {
            return Ok(Vec::new());
        }
        // Use jj log with a revset matching the given IDs
        let revset = ids
            .iter()
            .map(|id| id.as_str())
            .collect::<Vec<_>>()
            .join(" | ");
        let template = r#"commit_id ++ "\x00" ++ commit_id.short() ++ "\x00" ++ description ++ "\x00" ++ author.email() ++ "\x00" ++ committer.timestamp() ++ "\x01""#;
        let output = run_jj_command(
            &self.info.root_path,
            ["log", "-r", &revset, "--no-graph", "-T", 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_description(parts[2]);
            let author = parts[3].to_string();
            let time = DateTime::parse_from_rfc3339(parts[4])
                .map(|dt| dt.with_timezone(&Utc))
                .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];

        // Diff from the parent of the oldest commit to the working copy (@)
        let from_rev = format!("{}-", oldest);
        let diff_args = ["diff", "--from", &from_rev, "--to", "@", "--git"];
        let args = self.diff_args(&diff_args);
        let diff_output = run_jj_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::GitStyle, highlighter)?;
        apply_container_full_file_highlight(
            &self.info.root_path,
            &from_rev,
            None,
            &mut files,
            highlighter,
            jj_show_batch,
        )?;
        Ok(files)
    }
}

/// Fetch the full content of `paths` at `rev` in a single `jj file show`
/// subprocess. jj is much cheaper per-call than hg, but batching still avoids
/// repeated process startup when there are many container files in a diff.
fn jj_show_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\"");
    let path_strs: Vec<String> = paths
        .iter()
        .map(|p| p.to_string_lossy().into_owned())
        .collect();
    let mut args: Vec<&str> = vec!["file", "show", "-r", rev, "-T", &template];
    args.extend(path_strs.iter().map(String::as_str));
    let output = run_jj_command(root, &args)?;
    Ok(parse_batched_files(&output))
}

/// Run a jj command and return its stdout.
fn run_jj_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("jj")
        .arg("--color=never")
        .current_dir(root)
        .args(args.iter().map(|arg| arg.as_ref()))
        .output()
        .map_err(|e| TuicrError::VcsCommand(format!("Failed to run jj: {}", 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!(
            "jj {} 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;
    use std::sync::Once;

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

    /// Point `JJ_CONFIG` at a throwaway config file that disables commit
    /// signing, for the lifetime of this test process.
    ///
    /// `--config signing.behavior=drop` on `jj_cmd()`'s own invocations
    /// isn't enough: `JjBackend` methods under test (e.g.
    /// `get_working_tree_diff`) shell out to `jj` themselves via
    /// `run_jj_command`, and that production code path must stay
    /// unmodified so real users' signing config keeps working. Overriding
    /// `JJ_CONFIG` on the test process's environment means every `jj`
    /// child process spawned for the rest of this run inherits it,
    /// including ones spawned by the backend under test, without ever
    /// touching the developer's real `~/.config/jj`. This keeps
    /// contributors with `signing.behavior = "own"` configured globally
    /// from being prompted to sign throwaway commits in temp repos.
    fn disable_jj_signing_for_tests() {
        static INIT: Once = Once::new();
        INIT.call_once(|| {
            let config_path = std::env::temp_dir().join("tuicr-test-jj-config.toml");
            fs::write(
                &config_path,
                "signing.behavior = \"drop\"\n\
                 user.name = \"Tuicr Test\"\n\
                 user.email = \"tuicr@example.com\"\n",
            )
            .expect("failed to write throwaway jj test config");
            // SAFETY: guarded by `Once`, so this runs exactly once, before
            // any test spawns a `jj` child process; nothing else in this
            // crate reads or writes `JJ_CONFIG`.
            unsafe {
                std::env::set_var("JJ_CONFIG", &config_path);
            }
        });
    }

    /// `jj` invocation with commit signing disabled. Overrides any global
    /// `signing.behavior = "own"` config so contributors who sign their own
    /// commits aren't prompted to sign throwaway commits in these temp repos.
    fn jj_cmd() -> Command {
        disable_jj_signing_for_tests();
        let mut cmd = Command::new("jj");
        cmd.args(["--config", "signing.behavior=drop"]);
        cmd
    }

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

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

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

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

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

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

        // Initialize jj repo (jj init creates a git-backed repo by default)
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            eprintln!(
                "jj git init failed: {}",
                String::from_utf8_lossy(&output.stderr)
            );
            return None;
        }

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

        // Snapshot the changes (jj auto-tracks files)
        jj_cmd()
            .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_jj_discover() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

        // Use discover_in to avoid set_current_dir race conditions
        let backend = discover_in(temp.path()).expect("Failed to discover jj 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::Jujutsu);
        assert!(!info.head_commit.is_empty());
    }

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

        // Use from_path directly to avoid set_current_dir race conditions
        let backend = JjBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create jj 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::Jujutsu);

        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_jj_diff_surfaces_noop_file_when_whitespace_only_diff_is_empty() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

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

        let files = backend
            .get_working_tree_diff(&SyntaxHighlighter::default())
            .expect("whitespace-only edit may surface as a no-op diff file");
        assert_eq!(files.len(), 1);
        assert!(files[0].hunks.is_empty());

        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_jj_working_tree_with_commits_surfaces_noop_file_for_whitespace_only_diff() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

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

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

        let files = backend
            .get_working_tree_with_commits_diff(
                std::slice::from_ref(&whitespace_commit.id),
                &SyntaxHighlighter::default(),
            )
            .expect("whitespace-only edit may surface as a no-op diff file");
        assert_eq!(files.len(), 1);
        assert!(files[0].hunks.is_empty());

        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_jj_fetch_context_lines() {
        let Some(temp) = setup_test_repo() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

        // Use from_path directly to avoid set_current_dir race conditions
        let backend = JjBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create jj 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 jj is not available.
    fn setup_test_repo_with_commits() -> Option<tempfile::TempDir> {
        if !jj_available() {
            return None;
        }

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            eprintln!(
                "jj git init failed: {}",
                String::from_utf8_lossy(&output.stderr)
            );
            return None;
        }

        // First commit
        fs::write(root.join("file1.txt"), "first file\n").expect("Failed to write file");
        jj_cmd()
            .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");
        jj_cmd()
            .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");
        jj_cmd()
            .args(["commit", "-m", "Third commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        Some(temp_dir)
    }

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

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

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

        // jj creates a working copy commit on top, so we may have 4 commits
        assert!(commits.len() >= 3, "Expected at least 3 commits");

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

        // Check that our commit messages are present (may not be in exact order due to working copy)
        let summaries: Vec<_> = commits.iter().map(|c| c.summary.as_str()).collect();
        assert!(
            summaries.iter().any(|s| s.contains("First commit")),
            "Expected 'First commit' in {:?}",
            summaries
        );
        assert!(
            summaries.iter().any(|s| s.contains("Second commit")),
            "Expected 'Second commit' in {:?}",
            summaries
        );
        assert!(
            summaries.iter().any(|s| s.contains("Third commit")),
            "Expected 'Third commit' in {:?}",
            summaries
        );
    }

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

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

        let commits = backend
            .get_recent_commits(0, 10)
            .expect("Failed to get commits");
        assert!(commits.len() >= 3, "Expected at least 3 commits");

        // Find the commits with our messages (skip empty working copy commit)
        let named_commits: Vec<_> = commits
            .iter()
            .filter(|c| {
                c.summary.contains("First commit")
                    || c.summary.contains("Second commit")
                    || c.summary.contains("Third commit")
            })
            .collect();

        if named_commits.len() >= 2 {
            // Get diff for two commits
            let oldest = &named_commits[named_commits.len() - 1]; // First commit
            let newest = &named_commits[0]; // Third commit

            let commit_ids = vec![oldest.id.clone(), newest.id.clone()];
            let diff = backend
                .get_commit_range_diff(
                    &ResolvedRevisionRange::from_owned_commit_ids(
                        commit_ids,
                        RevisionDiffTarget::CommitList,
                    ),
                    &SyntaxHighlighter::default(),
                )
                .expect("Failed to get commit range diff");

            // Should have changes
            assert!(!diff.is_empty(), "Expected non-empty diff");
        }
    }

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

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            return None;
        }

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

        // Rename the file using jj file track after manual rename
        fs::rename(root.join("original.txt"), root.join("renamed.txt"))
            .expect("Failed to rename file");

        Some(temp_dir)
    }

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

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

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

        // jj should detect the rename
        // Note: jj may show this as delete + add if it doesn't detect 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();
        }
    }

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

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            return None;
        }

        // 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");

        Some(temp_dir)
    }

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

        let backend = JjBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create jj 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)
        let path = file.display_path();
        assert_eq!(path.to_str().unwrap(), "image.png");
        assert_eq!(file.status, FileStatus::Added);
    }

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

        let root = temp.path();

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

        // Delete the binary file
        fs::remove_file(root.join("image.png")).expect("Failed to delete file");

        let backend = JjBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create jj 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)
        let path = file.display_path();
        assert_eq!(path.to_str().unwrap(), "image.png");
        assert_eq!(file.status, FileStatus::Deleted);
    }

    /// Create a test repo with a bookmark on the current revision.
    fn setup_test_repo_with_bookmark_on_current() -> Option<tempfile::TempDir> {
        if !jj_available() {
            return None;
        }

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            return None;
        }

        // Create initial file and commit
        fs::write(root.join("file.txt"), "content\n").expect("Failed to write file");
        jj_cmd()
            .args(["commit", "-m", "Initial commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Create a bookmark on @
        jj_cmd()
            .args(["bookmark", "create", "my-feature", "-r", "@"])
            .current_dir(root)
            .output()
            .expect("Failed to create bookmark");

        Some(temp_dir)
    }

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

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

        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");
        if !output.status.success() {
            return None;
        }

        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");

        jj_cmd()
            .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_jj_highlights_vue_script_hunk_using_full_file_context() {
        let Some(temp) = setup_test_repo_with_vue() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

        let backend = JjBackend::from_path(temp.path().to_path_buf(), DiffWhitespaceMode::Normal)
            .expect("Failed to create jj 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_jj_bookmark_on_current_revision() {
        let Some(temp) = setup_test_repo_with_bookmark_on_current() else {
            eprintln!("Skipping test: jj command not available");
            return;
        };

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

        assert_eq!(
            info.branch_name.as_deref(),
            Some("my-feature"),
            "Expected bookmark 'my-feature' to be detected"
        );
    }

    /// Create a test repo with a bookmark on an ancestor revision.
    fn setup_test_repo_with_bookmark_on_ancestor() -> Option<tempfile::TempDir> {
        if !jj_available() {
            return None;
        }

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            return None;
        }

        // Create initial file and commit
        fs::write(root.join("file.txt"), "content\n").expect("Failed to write file");
        jj_cmd()
            .args(["commit", "-m", "Initial commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        // Create a bookmark on the commit we just made (now @-)
        jj_cmd()
            .args(["bookmark", "create", "main", "-r", "@-"])
            .current_dir(root)
            .output()
            .expect("Failed to create bookmark");

        // Make another commit so @ is ahead of the bookmark
        fs::write(root.join("file2.txt"), "more content\n").expect("Failed to write file");
        jj_cmd()
            .args(["commit", "-m", "Second commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        Some(temp_dir)
    }

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

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

        assert_eq!(
            info.branch_name.as_deref(),
            Some("main"),
            "Expected ancestor bookmark 'main' to be detected"
        );
    }

    /// Create a test repo with no bookmarks.
    fn setup_test_repo_without_bookmarks() -> Option<tempfile::TempDir> {
        if !jj_available() {
            return None;
        }

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

        // Initialize jj repo
        let output = jj_cmd()
            .args(["git", "init"])
            .current_dir(root)
            .output()
            .expect("Failed to init jj repo");

        if !output.status.success() {
            return None;
        }

        // Create initial file and commit (no bookmarks)
        fs::write(root.join("file.txt"), "content\n").expect("Failed to write file");
        jj_cmd()
            .args(["commit", "-m", "Initial commit"])
            .current_dir(root)
            .output()
            .expect("Failed to commit");

        Some(temp_dir)
    }

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

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

        assert!(
            info.branch_name.is_none(),
            "Expected no bookmark when none exist, got {:?}",
            info.branch_name
        );
    }

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

        // Force color output regardless of whether stdout is a tty
        let output = Command::new("jj")
            .args(["config", "set", "--repo", "ui.color", "always"])
            .current_dir(temp.path())
            .output()
            .expect("Failed to configure jj colors");

        assert!(
            output.status.success(),
            "Failed to enable colors: {}",
            String::from_utf8_lossy(&output.stderr)
        );

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

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

        for commit in commits {
            assert!(
                !commit.id.contains('\x1b'),
                "Commit id contains ANSI escapes: {:?}",
                commit.id
            );

            assert!(
                !commit.short_id.contains('\x1b'),
                "Short id contains ANSI escapes: {:?}",
                commit.short_id
            );
        }
    }
}
