use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;

use anyhow::Result;

use vcs_runner::{
    is_transient_error, run_jj, run_jj_cancellable, run_jj_with_retry,
    run_jj_with_retry_cancellable,
};

use super::shared::{assemble_results, process_files_parallel, FileProcessResult};

/// Prepend `--ignore-working-copy` to skip jj's auto-snapshot.
/// Only the first command per refresh cycle needs to snapshot; subsequent
/// commands reuse the snapshot and avoid writing to op_store/working_copy.
fn no_snapshot<'a>(args: &[&'a str]) -> Vec<&'a str> {
    let mut full = Vec::with_capacity(args.len() + 1);
    full.push("--ignore-working-copy");
    full.extend_from_slice(args);
    full
}

use crate::diff::{compute_four_way_diff, compute_four_way_diff_cancellable, DiffInput, FileDiff};
use crate::image_diff::is_image_file;
use crate::limits::DiffMetrics;
use crate::vcs::{ComparisonContext, DiffBase, RefreshResult, StackPosition, UpstreamDivergence, VcsBackend, VcsEventType, VcsWatchPaths};

/// Jujutsu (jj) backend for branchdiff.
pub struct JjVcs {
    repo_path: PathBuf,
    /// Revset for the base of comparison: `trunk()` when available, `@-` otherwise.
    from_rev: String,
    /// Whether to diff from the fork point or the trunk tip.
    /// Stored as AtomicU8 for interior mutability (Vcs trait uses &self).
    /// 0 = ForkPoint, 1 = TrunkTip.
    diff_base: AtomicU8,
}

/// Probe whether `trunk()` points to a real remote-tracking bookmark.
/// `trunk()` falls back to `root()` when no remote exists, which would diff
/// the entire repo history — so only use it when it resolves to an actual branch.
fn resolve_base_rev(repo_path: &Path) -> String {
    run_jj(repo_path, &[
        "log", "-r", "trunk() ~ root()", "--no-graph",
        "--limit", "1", "-T", "change_id.short(12)",
    ])
    .map(|o| {
        if o.stdout_lossy().trim().is_empty() {
            "@-".to_string()
        } else {
            "trunk()".to_string()
        }
    })
    .unwrap_or_else(|_| "@-".to_string())
}

/// Find the fork point: the most recent common ancestor of trunk() and @.
/// Returns None if @ is already on top of trunk (no divergence) or if
/// from_rev isn't trunk().
fn resolve_fork_point(
    repo_path: &Path,
    from_rev: &str,
    cancel: &Arc<AtomicBool>,
) -> Option<String> {
    if from_rev != "trunk()" {
        return None;
    }

    let args = no_snapshot(&[
        "log", "-r", "heads(::trunk() & ::@)", "--no-graph",
        "--limit", "1", "-T", "commit_id.short(12)",
    ]);
    let fork_id = run_jj_cancellable(repo_path, &args, Arc::clone(cancel)).ok()
        .map(|o| o.stdout_lossy().trim().to_string())
        .filter(|s| !s.is_empty())?;

    // Check if fork point equals trunk tip — if so, no divergence
    let trunk_commit = {
        let args = no_snapshot(&[
            "log", "-r", "trunk()", "--no-graph", "--limit", "1",
            "-T", "commit_id.short(12)",
        ]);
        run_jj_cancellable(repo_path, &args, Arc::clone(cancel)).ok()
            .map(|o| o.stdout_lossy().trim().to_string())?
    };

    if fork_id == trunk_commit {
        // @ is already on top of trunk — no divergence
        return None;
    }

    Some(fork_id)
}

/// Compute upstream divergence for jj: how many commits and which files
/// changed between the fork point and trunk().
fn compute_jj_divergence(
    repo_path: &Path,
    fork_point: &str,
    cancel: &Arc<AtomicBool>,
) -> Option<UpstreamDivergence> {
    // Count commits between fork point and trunk
    let revset = format!("\"{}\"..trunk()", fork_point);
    let count_args = no_snapshot(&[
        "log", "-r", &revset,
        "--no-graph", "-T", r#""\n""#,
    ]);
    let behind_count = run_jj_cancellable(repo_path, &count_args, Arc::clone(cancel))
        .map(|o| o.stdout_lossy().lines().filter(|l| !l.is_empty()).count())
        .unwrap_or(0);

    if behind_count == 0 {
        return None;
    }

    // Get files changed between fork point and trunk
    let diff_args = no_snapshot(&[
        "diff", "--from", fork_point, "--to", "trunk()", "--summary",
    ]);
    let upstream_files = run_jj_cancellable(repo_path, &diff_args, Arc::clone(cancel))
        .map(|o| {
            parse_jj_summary(&o.stdout_lossy())
                .into_iter()
                .map(|f| f.path)
                .collect()
        })
        .unwrap_or_default();

    Some(UpstreamDivergence {
        behind_count,
        upstream_files,
    })
}

/// Info about the stack tip when @ is not at the top.
struct StackTip {
    /// Short change_id of the chosen tip commit.
    change_id: String,
    /// How many independent heads descend from @ (1 = linear stack).
    head_count: usize,
}

/// Find the tip(s) of the mutable stack above @.
/// Returns None when @ is already the tip or from_rev isn't trunk().
fn resolve_stack_tip(
    repo_path: &Path,
    from_rev: &str,
    cancel: &Arc<AtomicBool>,
) -> Option<StackTip> {
    if from_rev != "trunk()" {
        return None;
    }

    let args = no_snapshot(&[
        "log", "-r", "heads(trunk()..(@::))", "--no-graph",
        "-T", r#"change_id.short(12) ++ "\n""#,
    ]);
    let output = run_jj_cancellable(repo_path, &args, Arc::clone(cancel)).ok()?;
    let stdout = output.stdout_lossy();
    let heads: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();

    if heads.is_empty() {
        return None;
    }

    // Check if the only head IS @ (meaning @ is already at the tip)
    let at_id = get_change_id_static(repo_path, "@", cancel)?;
    if heads.len() == 1 && heads[0].trim() == at_id.trim() {
        return None;
    }

    Some(StackTip {
        change_id: heads[0].trim().to_string(),
        head_count: heads.len(),
    })
}

/// Info about the bookmark boundary for the current stack position.
struct BookmarkBoundary {
    /// Name of the current bookmark.
    bookmark_name: String,
    /// Files changed between the boundary and @, used to filter which files
    /// belong to the current bookmark vs earlier bookmarks.
    changed_files: HashSet<String>,
}

/// Find the bookmark boundary: the nearest ancestor bookmark below the current
/// bookmark's scope. Returns the boundary revision and current bookmark name.
///
/// Strategy:
/// 1. Find the bookmark at or above @ (the bookmark @ belongs to).
/// 2. Find the nearest ancestor bookmark below that one.
fn resolve_bookmark_boundary(
    repo_path: &Path,
    from_rev: &str,
    cancel: &Arc<AtomicBool>,
) -> Option<BookmarkBoundary> {
    if from_rev != "trunk()" {
        return None;
    }

    // Step 1: Find the current bookmark — at @ or the nearest descendant with a bookmark
    let template = r#"bookmarks.join(",") ++ "\0" ++ change_id.short(12)"#;
    let args = no_snapshot(&[
        "log", "-r", "latest((@:: | @) & bookmarks())", "--no-graph", "--limit", "1",
        "-T", template,
    ]);
    let output = run_jj_cancellable(repo_path, &args, Arc::clone(cancel)).ok()?;
    let stdout = output.stdout_lossy();
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return None;
    }

    let parts: Vec<&str> = trimmed.splitn(2, '\0').collect();
    if parts.len() < 2 {
        return None;
    }
    let bookmark_name = parts[0].trim().trim_end_matches('*').to_string();
    let current_bm_id = parts[1].trim().to_string();

    if bookmark_name.is_empty() {
        return None;
    }

    // Step 2: Find the previous bookmark (nearest ancestor bookmark below the current one)
    // Use both local and remote bookmarks — stacks often have only remote-tracking bookmarks
    // for already-pushed segments.
    let revset = format!(
        "latest((trunk()..\"{}\"-) & (bookmarks() | remote_bookmarks()))",
        current_bm_id
    );
    let prev_args = no_snapshot(&[
        "log", "-r", &revset, "--no-graph", "--limit", "1",
        "-T", "change_id.short(12)",
    ]);
    let boundary_id = run_jj_cancellable(repo_path, &prev_args, Arc::clone(cancel))
        .ok()
        .map(|o| o.stdout_lossy().trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| from_rev.to_string());

    // Step 3: Get the list of files changed between boundary and @
    let range = format!("\"{}\"..@", boundary_id);
    let diff_args = no_snapshot(&[
        "diff", "-r", &range, "--name-only",
    ]);
    let changed_files: HashSet<String> = run_jj_cancellable(repo_path, &diff_args, Arc::clone(cancel))
        .map(|o| {
            o.stdout_lossy()
                .lines()
                .map(|l| l.trim().to_string())
                .filter(|l| !l.is_empty())
                .collect()
        })
        .unwrap_or_default();

    Some(BookmarkBoundary {
        bookmark_name,
        changed_files,
    })
}

/// Mark each DiffLine in a FileDiff with bookmark provenance.
///
/// Uses the file content at the bookmark boundary revision to determine which
/// lines were introduced before vs. after the boundary.
/// Mark each line's bookmark provenance based on whether the file was changed
/// in the current bookmark's scope (between the previous bookmark and @).
fn mark_bookmark_provenance(file_diff: &mut crate::diff::FileDiff, file_in_current_bookmark: bool) {
    use crate::diff::LineSource;

    for line in &mut file_diff.lines {
        line.in_current_bookmark = Some(match line.source {
            // Change lines: belong to the current bookmark only if the file was
            // modified between the previous bookmark boundary and @.
            LineSource::Committed | LineSource::CanceledCommitted
            | LineSource::DeletedBase | LineSource::Staged
            | LineSource::DeletedCommitted | LineSource::CanceledStaged
            | LineSource::Unstaged | LineSource::DeletedStaged => file_in_current_bookmark,

            LineSource::Base if line.change_source.is_some() => file_in_current_bookmark,

            LineSource::Base | LineSource::FileHeader | LineSource::Elided => false,
        });
    }
}

/// Get a change_id without requiring a JjVcs instance.
fn get_change_id_static(repo_path: &Path, rev: &str, cancel: &Arc<AtomicBool>) -> Option<String> {
    let args = no_snapshot(&[
        "log", "-r", rev, "--no-graph", "--limit", "1",
        "-T", "change_id.short(12)",
    ]);
    run_jj_cancellable(repo_path, &args, Arc::clone(cancel))
        .ok()
        .map(|o| o.stdout_lossy().trim().to_string())
}

/// Compute @'s position in the stack from trunk to tip.
/// Returns (1-based position of @, total commits in stack).
fn compute_stack_position(repo_path: &Path, tip_id: &str) -> Option<(usize, usize)> {
    let revset = format!("trunk()..\"{}\"", tip_id);
    let args = no_snapshot(&[
        "log", "-r", &revset, "--no-graph",
        "-T", r#"if(self.contained_in("@"), "@", ".") ++ "\n""#,
    ]);
    let output = run_jj(repo_path, &args).ok()?;
    let stdout = output.stdout_lossy();
    let entries: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
    let total = entries.len();
    if total == 0 {
        return None;
    }

    // jj log outputs newest-first; @ marker tells us position
    // Position from bottom: total - index_from_top
    let index_from_top = entries.iter().position(|e| e.trim() == "@")?;
    let current = total - index_from_top;

    Some((current, total))
}

impl JjVcs {
    pub fn new(repo_path: PathBuf) -> Result<Self> {
        let from_rev = resolve_base_rev(&repo_path);
        Ok(Self {
            repo_path,
            from_rev,
            diff_base: AtomicU8::new(0),
        })
    }

    fn load_diff_base(&self) -> DiffBase {
        match self.diff_base.load(Ordering::Relaxed) {
            0 => DiffBase::ForkPoint,
            _ => DiffBase::TrunkTip,
        }
    }

    fn run_jj(&self, args: &[&str]) -> Result<String> {
        let output = run_jj(&self.repo_path, args)?;
        Ok(output.stdout_lossy().into_owned())
    }

    fn run_jj_bytes(&self, args: &[&str]) -> Result<Option<Vec<u8>>> {
        match run_jj(&self.repo_path, args) {
            Ok(output) => Ok(Some(output.stdout)),
            Err(e) if e.is_non_zero_exit() => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    /// Get changed files between a from revision and effective_to.
    /// The first call per refresh triggers the working copy auto-snapshot;
    /// all subsequent commands use `--ignore-working-copy`.
    fn get_changed_files_with_from(
        &self,
        from: &str,
        effective_to: &str,
        cancel: &Arc<AtomicBool>,
    ) -> Result<Vec<ChangedFile>> {
        let output = run_jj_with_retry_cancellable(
            &self.repo_path,
            &["diff", "--from", from, "--to", effective_to, "--summary"],
            is_transient_error,
            Arc::clone(cancel),
        )?;
        Ok(parse_jj_summary(&output.stdout_lossy()))
    }

    /// Detect binary files by checking --stat output for "(binary)" markers.
    /// Uses `--ignore-working-copy` since the snapshot is already fresh from
    /// `get_changed_files`.
    fn get_binary_files_set(
        &self,
        from: &str,
        effective_to: &str,
        cancel: &Arc<AtomicBool>,
    ) -> HashSet<String> {
        let args = no_snapshot(&[
            "diff", "--from", from, "--to", effective_to, "--stat",
        ]);
        match run_jj_with_retry_cancellable(
            &self.repo_path,
            &args,
            is_transient_error,
            Arc::clone(cancel),
        ) {
            Ok(output) => parse_binary_from_stat(&output.stdout_lossy()),
            Err(_) => HashSet::new(),
        }
    }

    fn get_file_bytes_at_rev(&self, file_path: &str, rev: &str) -> Result<Option<Vec<u8>>> {
        self.run_jj_bytes(&["file", "show", "-r", rev, file_path])
    }

    /// Get the current change ID for a revision.
    fn get_change_id(&self, rev: &str) -> Result<String> {
        let output = self.run_jj(&["log", "-r", rev, "-T", "change_id.short(12)", "--no-graph", "--limit", "1"])?;
        Ok(output.trim().to_string())
    }

    #[cfg(test)]
    fn get_bookmarks(&self, rev: &str) -> Option<String> {
        let output = self.run_jj(&["log", "-r", rev, "-T", "bookmarks", "--no-graph", "--limit", "1"]).ok()?;
        let trimmed = output.trim().trim_end_matches('*');
        if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
    }

    #[cfg(test)]
    fn rev_label(&self, rev: &str) -> String {
        self.get_bookmarks(rev)
            .unwrap_or_else(|| self.get_change_id(rev).unwrap_or_else(|_| rev.to_string()))
    }

    /// Fetch bookmarks and change_id for a revision in a single command,
    /// using `--ignore-working-copy` to avoid redundant auto-snapshots.
    /// Returns (change_id, display_label).
    fn rev_metadata_no_snapshot(&self, rev: &str) -> (String, String) {
        let template = r#"bookmarks ++ "\0" ++ change_id.short(12) ++ "\0" ++ change_id.shortest(4)"#;
        let args = no_snapshot(&[
            "log", "-r", rev, "-T", template, "--no-graph", "--limit", "1",
        ]);
        match self.run_jj(&args) {
            Ok(raw) => parse_rev_metadata(&raw),
            Err(_) => (rev.to_string(), rev.to_string()),
        }
    }

    /// Check if repo is colocated (has .git directory alongside .jj).
    fn is_colocated(&self) -> bool {
        self.repo_path.join(".git").exists()
    }
}

/// Parse combined `bookmarks ++ "\0" ++ change_id ++ "\0" ++ shortest_id` template output.
/// Returns (change_id, display_label) where label annotates bookmarks with the shortest
/// unique change ID prefix: `"main (knmq)"`.
fn parse_rev_metadata(raw: &str) -> (String, String) {
    let raw = raw.trim();
    let parts: Vec<&str> = raw.splitn(3, '\0').collect();
    if parts.len() >= 2 {
        let bookmarks = parts[0].trim().trim_end_matches('*');
        let change_id = parts[1].trim().to_string();
        let shortest_id = parts.get(2).map(|s| s.trim()).unwrap_or("");
        let label = if bookmarks.is_empty() {
            if shortest_id.is_empty() { change_id.clone() } else { shortest_id.to_string() }
        } else if shortest_id.is_empty() {
            bookmarks.to_string()
        } else {
            format!("{shortest_id} ({bookmarks})")
        };
        (change_id, label)
    } else {
        (raw.to_string(), raw.to_string())
    }
}

/// Read file content at a revision without triggering auto-snapshot.
/// Free function for use in parallel contexts (rayon).
fn file_content_no_snapshot(
    repo_path: &Path,
    file_path: &str,
    rev: &str,
    cancel: &Arc<AtomicBool>,
) -> Option<String> {
    let args = no_snapshot(&["file", "show", "-r", rev, file_path]);
    run_jj_cancellable(repo_path, &args, Arc::clone(cancel))
        .ok()
        .map(|o| o.stdout_lossy().into_owned())
}

fn process_jj_file(
    repo_path: &Path,
    from_rev: &str,
    changed: &ChangedFile,
    binary_files: &HashSet<String>,
    tip_rev: Option<&str>,
    bookmark_changed_files: Option<&HashSet<String>>,
    cancel: &Arc<AtomicBool>,
) -> FileProcessResult {
    if cancel.load(Ordering::Relaxed) {
        return FileProcessResult::Cancelled;
    }

    if binary_files.contains(&changed.path) {
        if is_image_file(&changed.path) {
            return FileProcessResult::Image { path: changed.path.clone() };
        }
        return FileProcessResult::Binary { path: changed.path.clone() };
    }

    // Each `jj file show` is a separate subprocess and can take seconds for
    // large files. The cancel flag is plumbed into the subprocess itself
    // (via run_jj_cancellable inside file_content_no_snapshot), so a slow
    // file read gets killed mid-flight — not only between reads.
    let base_path = changed.old_path.as_deref().unwrap_or(&changed.path);
    let base = file_content_no_snapshot(repo_path, base_path, from_rev, cancel);
    if cancel.load(Ordering::Relaxed) {
        return FileProcessResult::Cancelled;
    }

    // @- content for the committed/staged boundary — try current path first,
    // fall back to old_path for files renamed in the current commit
    let parent = file_content_no_snapshot(repo_path, &changed.path, "@-", cancel)
        .or_else(|| {
            changed.old_path.as_deref()
                .and_then(|old| file_content_no_snapshot(repo_path, old, "@-", cancel))
        });
    if cancel.load(Ordering::Relaxed) {
        return FileProcessResult::Cancelled;
    }

    let index = file_content_no_snapshot(repo_path, &changed.path, "@", cancel);
    if cancel.load(Ordering::Relaxed) {
        return FileProcessResult::Cancelled;
    }
    let tip_content = tip_rev
        .and_then(|tip| file_content_no_snapshot(repo_path, &changed.path, tip, cancel));

    // When tip_rev is None (@ is at tip), working == index.
    // When tip_rev is Some, working is the tip content — even if None (file
    // deleted above @). TODO: algorithm.rs check_file_deletion collapses the
    // per-commit coloring into a flat DeletedStaged block. A proper fix needs
    // a new code path that preserves base/head/index provenance while marking
    // the file as eventually-deleted.
    let working = match tip_rev {
        Some(_) => tip_content.as_deref(),
        None => index.as_deref(),
    };

    let mut file_diff = compute_four_way_diff_cancellable(
        DiffInput {
            path: &changed.path,
            base: base.as_deref(),
            head: parent.as_deref(),
            index: index.as_deref(),
            working,
            old_path: changed.old_path.as_deref(),
        },
        cancel,
    );

    if let Some(bm_files) = bookmark_changed_files {
        let in_bookmark = bm_files.contains(&changed.path);
        mark_bookmark_provenance(&mut file_diff, in_bookmark);
    }

    FileProcessResult::Diff(file_diff)
}

/// Get the jj repo root from a path.
pub fn get_repo_root(path: &Path) -> Result<PathBuf> {
    let output = run_jj(path, &["root"])?;
    let root = output.stdout_lossy().trim().to_string();
    Ok(PathBuf::from(root))
}

impl crate::vcs::Vcs for JjVcs {
    fn repo_path(&self) -> &Path {
        &self.repo_path
    }

    fn comparison_context(&self) -> Result<ComparisonContext> {
        let template = r#"bookmarks ++ "\0" ++ change_id.short(12) ++ "\0" ++ change_id.shortest(4)"#;
        // First call triggers auto-snapshot to capture current working copy
        let from_label = match run_jj_with_retry(
            &self.repo_path,
            &["log", "-r", &self.from_rev, "-T", template, "--no-graph", "--limit", "1"],
            is_transient_error,
        ) {
            Ok(output) => parse_rev_metadata(&output.stdout_lossy()).1,
            Err(_) => self.from_rev.clone(),
        };

        // Second call skips snapshot (already fresh)
        let to_args = no_snapshot(&[
            "log", "-r", "@", "-T", template, "--no-graph", "--limit", "1",
        ]);
        let to_label = match run_jj_with_retry(&self.repo_path, &to_args, is_transient_error) {
            Ok(output) => parse_rev_metadata(&output.stdout_lossy()).1,
            Err(_) => "@".to_string(),
        };

        // stack_position is computed by refresh() and applied via
        // apply_refresh_result — no need to resolve it here too.
        Ok(ComparisonContext { from_label, to_label, stack_position: None, vcs_backend: VcsBackend::Jj, bookmark_name: None, divergence: None })
    }

    fn refresh(&self, cancel_flag: &Arc<AtomicBool>) -> Result<RefreshResult> {
        // Resolve fork point for divergence detection and fork-point mode
        let fork_point = resolve_fork_point(&self.repo_path, &self.from_rev, cancel_flag);
        let divergence = fork_point.as_deref()
            .and_then(|fp| compute_jj_divergence(&self.repo_path, fp, cancel_flag));

        // Determine the effective --from rev based on diff_base mode
        let effective_from = match self.load_diff_base() {
            DiffBase::ForkPoint => fork_point.as_deref().unwrap_or(&self.from_rev),
            DiffBase::TrunkTip => &self.from_rev,
        };

        // Resolve stack tip — determines if @ is mid-stack
        let stack_tip = resolve_stack_tip(&self.repo_path, &self.from_rev, cancel_flag);
        let effective_to = stack_tip
            .as_ref()
            .map(|t| t.change_id.as_str())
            .unwrap_or("@");
        let tip_rev = stack_tip.as_ref().map(|t| t.change_id.as_str());

        // Resolve bookmark boundary for BookmarkOnly view mode
        let bookmark_boundary = resolve_bookmark_boundary(&self.repo_path, &self.from_rev, cancel_flag);
        let bookmark_changed_files = bookmark_boundary.as_ref().map(|b| &b.changed_files);

        // First command — triggers working copy auto-snapshot
        let changed_files = self.get_changed_files_with_from(effective_from, effective_to, cancel_flag)?;

        if cancel_flag.load(Ordering::Relaxed) {
            anyhow::bail!("refresh cancelled");
        }

        // All subsequent commands use --ignore-working-copy
        let binary_files = self.get_binary_files_set(effective_from, effective_to, cancel_flag);

        if cancel_flag.load(Ordering::Relaxed) {
            anyhow::bail!("refresh cancelled");
        }

        let results = process_files_parallel(&changed_files, cancel_flag, |changed| {
            process_jj_file(
                &self.repo_path,
                effective_from,
                changed,
                &binary_files,
                tip_rev,
                bookmark_changed_files,
                cancel_flag,
            )
        });

        if cancel_flag.load(Ordering::Relaxed) {
            anyhow::bail!("refresh cancelled");
        }

        let assembled = assemble_results(results);
        let files = assembled.files;
        let all_lines = assembled.lines;

        let stack_position = stack_tip.as_ref().and_then(|tip| {
            let (current, total) = compute_stack_position(&self.repo_path, &tip.change_id)?;
            Some(StackPosition {
                current,
                total,
                head_count: tip.head_count,
            })
        });

        let metrics = DiffMetrics {
            total_lines: all_lines.len(),
            file_count: files.len(),
        };
        let (base_identifier, base_label_str) =
            self.rev_metadata_no_snapshot(effective_from);
        let (_, current_branch_str) = self.rev_metadata_no_snapshot("@");

        let file_paths: Vec<&str> = files
            .iter()
            .filter_map(|f| f.lines.first())
            .filter_map(|l| l.file_path.as_deref())
            .collect();
        let file_links = crate::file_links::compute_file_links(&file_paths);

        Ok(RefreshResult {
            files,
            lines: all_lines,
            base_identifier,
            base_label: Some(base_label_str),
            current_branch: Some(current_branch_str),
            metrics,
            file_links,
            stack_position,
            bookmark_name: bookmark_boundary.map(|b| b.bookmark_name),
            revision_id: None,
            divergence,
        })
    }

    fn single_file_diff(&self, file_path: &str) -> Option<FileDiff> {
        // single_file_diff isn't surfaced as cancellable through the Vcs trait
        // — it's a quick path used for incremental updates. Pass a never-set
        // cancel flag so the subprocess calls compile against the cancellable
        // helpers. A future change can plumb a real cancel through if needed.
        let cancel = Arc::new(AtomicBool::new(false));
        let fork_point = resolve_fork_point(&self.repo_path, &self.from_rev, &cancel);
        let effective_from = match self.load_diff_base() {
            DiffBase::ForkPoint => fork_point.as_deref().unwrap_or(&self.from_rev),
            DiffBase::TrunkTip => &self.from_rev,
        };

        let stack_tip = resolve_stack_tip(&self.repo_path, &self.from_rev, &cancel);
        let effective_to = stack_tip
            .as_ref()
            .map(|t| t.change_id.as_str())
            .unwrap_or("@");

        // First command triggers auto-snapshot
        let changed_files = self.get_changed_files_with_from(effective_from, effective_to, &cancel).ok()?;
        let changed = changed_files.iter().find(|f| f.path == file_path);
        let old_path = changed.and_then(|f| f.old_path.as_deref());

        // Subsequent commands skip snapshot
        let base_path = old_path.unwrap_or(file_path);
        let base = file_content_no_snapshot(&self.repo_path, base_path, effective_from, &cancel);

        let parent = file_content_no_snapshot(&self.repo_path, file_path, "@-", &cancel)
            .or_else(|| {
                old_path.and_then(|old| file_content_no_snapshot(&self.repo_path, old, "@-", &cancel))
            });

        let index = file_content_no_snapshot(&self.repo_path, file_path, "@", &cancel);
        let tip_content = stack_tip
            .as_ref()
            .and_then(|t| file_content_no_snapshot(&self.repo_path, file_path, &t.change_id, &cancel));
        let has_tip = stack_tip.is_some();

        if base.is_none() && index.is_none() && tip_content.is_none() {
            return None;
        }

        let binary_files = self.get_binary_files_set(effective_from, effective_to, &cancel);
        if binary_files.contains(file_path) {
            return None;
        }

        let working = if has_tip { tip_content.as_deref() } else { index.as_deref() };

        let mut file_diff = compute_four_way_diff(DiffInput {
            path: file_path,
            base: base.as_deref(),
            head: parent.as_deref(),
            index: index.as_deref(),
            working,
            old_path,
        });

        // Mark bookmark provenance for BookmarkOnly view mode
        if let Some(boundary) = resolve_bookmark_boundary(&self.repo_path, &self.from_rev, &cancel) {
            let in_bookmark = boundary.changed_files.contains(file_path);
            mark_bookmark_provenance(&mut file_diff, in_bookmark);
        }

        Some(file_diff)
    }

    fn base_identifier(&self) -> Result<String> {
        self.get_change_id(&self.from_rev)
    }

    fn current_revision_id(&self) -> Result<String> {
        self.run_jj(&[
            "--ignore-working-copy",
            "log", "-r", "@", "--no-graph", "--limit", "1",
            "-T", "change_id.short(12)",
        ])
        .map(|s| s.trim().to_string())
    }

    fn base_file_bytes(&self, file_path: &str) -> Result<Option<Vec<u8>>> {
        self.get_file_bytes_at_rev(file_path, &self.from_rev)
    }

    fn working_file_bytes(&self, file_path: &str) -> Result<Option<Vec<u8>>> {
        self.get_file_bytes_at_rev(file_path, "@")
    }

    fn binary_files(&self) -> HashSet<String> {
        // Trait method has no cancel hook — use a never-set flag so the
        // cancellable helper compiles cleanly. This path is used outside the
        // refresh hot loop (e.g. image rendering callbacks) where mid-call
        // cancellation isn't currently wired through the trait.
        let cancel = Arc::new(AtomicBool::new(false));
        self.get_binary_files_set(&self.from_rev, "@", &cancel)
    }

    fn fetch(&self) -> Result<()> {
        if self.is_colocated() {
            self.run_jj(&["git", "fetch"])?;
        }
        Ok(())
    }

    fn has_conflicts(&self) -> Result<bool> {
        // jj conflict detection is a future enhancement
        Ok(false)
    }

    fn is_locked(&self) -> bool {
        // jj doesn't use lock files the same way git does
        false
    }

    fn watch_paths(&self) -> VcsWatchPaths {
        let jj_dir = self.repo_path.join(".jj");
        VcsWatchPaths {
            files: vec![jj_dir.join("working_copy/checkout")],
            recursive_dirs: vec![jj_dir.join("repo/op_store")],
        }
    }

    fn classify_event(&self, path: &Path) -> VcsEventType {
        let relative = path.strip_prefix(&self.repo_path).unwrap_or(path);
        let first = relative.components().next().map(|c| c.as_os_str());

        if first.is_some_and(|c| c == ".jj") {
            let path_str = relative.to_string_lossy();
            return if path_str.contains("working_copy/") {
                VcsEventType::RevisionChange
            } else {
                VcsEventType::Internal
            };
        }

        if first.is_some_and(|c| c == ".git") && self.is_colocated() {
            return VcsEventType::Internal;
        }

        VcsEventType::Source
    }

    fn backend(&self) -> VcsBackend {
        VcsBackend::Jj
    }

    fn set_diff_base(&self, base: DiffBase) {
        let val = match base {
            DiffBase::ForkPoint => 0,
            DiffBase::TrunkTip => 1,
        };
        self.diff_base.store(val, Ordering::Relaxed);
    }

    fn try_recover(
        &self,
        action: crate::update::RecoveryAction,
        cancel: &Arc<AtomicBool>,
    ) -> Result<()> {
        match action {
            crate::update::RecoveryAction::JjUpdateStale => {
                run_jj_cancellable(
                    &self.repo_path,
                    &["workspace", "update-stale"],
                    Arc::clone(cancel),
                )?;
                Ok(())
            }
        }
    }
}

/// Changed file from jj diff --summary output.
#[derive(Debug, Clone)]
struct ChangedFile {
    path: String,
    old_path: Option<String>,
}

/// Parse `jj diff --summary` output into changed files.
///
/// Renames use the format `R {old_path => new_path}`.
fn parse_jj_summary(output: &str) -> Vec<ChangedFile> {
    output
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() {
                return None;
            }
            let first = line.chars().next()?;
            if !matches!(first, 'M' | 'A' | 'D' | 'R' | 'C') {
                return None;
            }
            let rest = line[1..].trim();
            if rest.is_empty() {
                return None;
            }
            if first == 'R' {
                parse_rename(rest)
            } else {
                Some(ChangedFile { path: rest.to_string(), old_path: None })
            }
        })
        .collect()
}

/// Parse jj rename format: `{old_path => new_path}`
fn parse_rename(s: &str) -> Option<ChangedFile> {
    let s = s.strip_prefix('{')?.strip_suffix('}')?;
    let (old, new) = s.split_once(" => ")?;
    let old = old.trim();
    let new = new.trim();
    if new.is_empty() {
        return None;
    }
    Some(ChangedFile {
        path: new.to_string(),
        old_path: Some(old.to_string()),
    })
}

/// Parse `jj diff --stat` output to find binary files (marked with "(binary)").
///
/// Handles renamed files: `{old => new} | (binary)` extracts just the new name.
fn parse_binary_from_stat(output: &str) -> HashSet<String> {
    output
        .lines()
        .filter(|line| line.contains("(binary)"))
        .filter_map(|line| {
            let raw_path = line.split('|').next()?.trim();
            if raw_path.is_empty() {
                return None;
            }
            // Renames show as "{old => new}" — extract the new name
            let path = if let Some(inner) = raw_path.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
                inner.split(" => ").last().unwrap_or(inner).trim()
            } else if raw_path.contains(" => ") {
                raw_path.split(" => ").last().unwrap_or(raw_path).trim()
            } else {
                raw_path
            };
            if path.is_empty() { None } else { Some(path.to_string()) }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;
    use crate::diff::LineSource;
    use crate::vcs::Vcs;

    // === parse_jj_summary tests ===

    #[test]
    fn test_parse_summary_modified() {
        let files = parse_jj_summary("M file.txt\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "file.txt");
        assert!(files[0].old_path.is_none());
    }

    #[test]
    fn test_parse_summary_added() {
        let files = parse_jj_summary("A new_file.txt\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "new_file.txt");
        assert!(files[0].old_path.is_none());
    }

    #[test]
    fn test_parse_summary_deleted() {
        let files = parse_jj_summary("D old_file.txt\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "old_file.txt");
    }

    #[test]
    fn test_parse_summary_renamed() {
        let files = parse_jj_summary("R {old_name.txt => new_name.txt}\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "new_name.txt");
        assert_eq!(files[0].old_path.as_deref(), Some("old_name.txt"));
    }

    #[test]
    fn test_parse_summary_renamed_with_directory() {
        let files = parse_jj_summary("R {src/old.rs => src/new.rs}\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "src/new.rs");
        assert_eq!(files[0].old_path.as_deref(), Some("src/old.rs"));
    }

    #[test]
    fn test_parse_summary_multiple() {
        let output = "M file1.txt\nA file2.txt\nD file3.txt\n";
        let files = parse_jj_summary(output);
        assert_eq!(files.len(), 3);
        assert_eq!(files[0].path, "file1.txt");
        assert_eq!(files[1].path, "file2.txt");
        assert_eq!(files[2].path, "file3.txt");
    }

    #[test]
    fn test_parse_summary_mixed_with_rename() {
        let output = "M file.txt\nR {old.rs => new.rs}\nA added.txt\n";
        let files = parse_jj_summary(output);
        assert_eq!(files.len(), 3);
        assert_eq!(files[0].path, "file.txt");
        assert!(files[0].old_path.is_none());
        assert_eq!(files[1].path, "new.rs");
        assert_eq!(files[1].old_path.as_deref(), Some("old.rs"));
        assert_eq!(files[2].path, "added.txt");
        assert!(files[2].old_path.is_none());
    }

    #[test]
    fn test_parse_summary_empty() {
        let files = parse_jj_summary("");
        assert!(files.is_empty());
    }

    #[test]
    fn test_parse_summary_skips_blank_lines() {
        let output = "M file.txt\n\nA other.txt\n";
        let files = parse_jj_summary(output);
        assert_eq!(files.len(), 2);
    }

    #[test]
    fn test_parse_summary_path_with_spaces() {
        let files = parse_jj_summary("M path with spaces.txt\n");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "path with spaces.txt");
    }

    #[test]
    fn test_parse_rename_malformed_no_braces() {
        let files = parse_jj_summary("R old.txt => new.txt\n");
        assert!(files.is_empty(), "rename without braces should be skipped");
    }

    #[test]
    fn test_parse_rename_malformed_no_arrow() {
        let files = parse_jj_summary("R {old.txt new.txt}\n");
        assert!(files.is_empty(), "rename without => should be skipped");
    }

    // === parse_binary_from_stat tests ===

    #[test]
    fn test_parse_binary_from_stat_detects_binary() {
        let output = "image.png | (binary)\nfile.txt  | 2 +-\n1 file changed\n";
        let binaries = parse_binary_from_stat(output);
        assert!(binaries.contains("image.png"));
        assert!(!binaries.contains("file.txt"));
    }

    #[test]
    fn test_parse_binary_from_stat_empty() {
        let binaries = parse_binary_from_stat("file.txt | 2 +-\n");
        assert!(binaries.is_empty());
    }

    #[test]
    fn test_parse_binary_from_stat_multiple() {
        let output = "a.png | (binary)\nb.jpg | (binary)\nc.txt | 1 +\n";
        let binaries = parse_binary_from_stat(output);
        assert_eq!(binaries.len(), 2);
        assert!(binaries.contains("a.png"));
        assert!(binaries.contains("b.jpg"));
    }

    #[test]
    fn test_parse_binary_from_stat_renamed_with_braces() {
        let output = "{original.bin => renamed.bin} | (binary)\n";
        let binaries = parse_binary_from_stat(output);
        assert!(binaries.contains("renamed.bin"), "should extract new name from rename");
        assert!(!binaries.contains("{original.bin => renamed.bin}"), "should not store raw rename format");
    }

    #[test]
    fn test_parse_binary_from_stat_renamed_without_braces() {
        let output = "original.bin => renamed.bin | (binary)\n";
        let binaries = parse_binary_from_stat(output);
        assert!(binaries.contains("renamed.bin"), "should extract new name from arrow format");
    }

    // === parse_rev_metadata tests ===

    #[test]
    fn test_parse_rev_metadata_with_bookmark() {
        let (change_id, label) = parse_rev_metadata("my-feature\0abcdef123456\n");
        assert_eq!(change_id, "abcdef123456");
        assert_eq!(label, "my-feature");
    }

    #[test]
    fn test_parse_rev_metadata_without_bookmark() {
        let (change_id, label) = parse_rev_metadata("\0abcdef123456\n");
        assert_eq!(change_id, "abcdef123456");
        assert_eq!(label, "abcdef123456");
    }

    #[test]
    fn test_parse_rev_metadata_strips_tracking_marker() {
        let (change_id, label) = parse_rev_metadata("main*\0abcdef123456\n");
        assert_eq!(change_id, "abcdef123456");
        assert_eq!(label, "main");
    }

    #[test]
    fn test_parse_rev_metadata_empty_string() {
        let (change_id, label) = parse_rev_metadata("");
        assert_eq!(change_id, "");
        assert_eq!(label, "");
    }

    #[test]
    fn test_parse_rev_metadata_no_separator() {
        let (change_id, label) = parse_rev_metadata("fallback_text");
        assert_eq!(change_id, "fallback_text");
        assert_eq!(label, "fallback_text");
    }

    // === no_snapshot helper tests ===

    #[test]
    fn test_no_snapshot_prepends_flag() {
        let args = no_snapshot(&["diff", "--from", "@-", "--to", "@"]);
        assert_eq!(args[0], "--ignore-working-copy");
        assert_eq!(args[1], "diff");
        assert_eq!(args.len(), 6);
    }

    // === classify_event tests ===

    #[test]
    fn test_classify_source_file() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/repo/src/main.rs")),
            VcsEventType::Source
        );
    }

    #[test]
    fn test_classify_jj_op_store_as_internal() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/repo/.jj/repo/op_store/heads")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_jj_working_copy() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/repo/.jj/working_copy/checkout")),
            VcsEventType::RevisionChange
        );
    }

    #[test]
    fn test_classify_jj_internal() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/repo/.jj/repo/store/something")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_path_outside_repo() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/other/file.rs")),
            VcsEventType::Source
        );
    }

    #[test]
    fn test_classify_git_index_as_internal_in_colocated_repo() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
        assert_eq!(
            vcs.classify_event(&temp.path().join(".git/index")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_git_objects_as_internal_in_colocated_repo() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
        assert_eq!(
            vcs.classify_event(&temp.path().join(".git/objects/ab/cd1234")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_git_refs_as_internal_in_colocated_repo() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
        assert_eq!(
            vcs.classify_event(&temp.path().join(".git/refs/jj/keep/abc123")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_git_head_as_internal_in_colocated_repo() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
        assert_eq!(
            vcs.classify_event(&temp.path().join(".git/HEAD")),
            VcsEventType::Internal
        );
    }

    #[test]
    fn test_classify_git_path_as_source_in_non_colocated_repo() {
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        assert_eq!(
            vcs.classify_event(Path::new("/repo/.git/index")),
            VcsEventType::Source
        );
    }

    #[test]
    fn test_classify_source_file_in_colocated_repo() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
        assert_eq!(
            vcs.classify_event(&temp.path().join("src/main.rs")),
            VcsEventType::Source
        );
    }

    // === watch_paths tests ===

    #[test]
    fn test_watch_paths() {
        use crate::vcs::Vcs;
        let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
        let paths = vcs.watch_paths();
        assert!(paths.files.contains(&PathBuf::from("/repo/.jj/working_copy/checkout")));
        assert!(paths.recursive_dirs.contains(&PathBuf::from("/repo/.jj/repo/op_store")));
    }

    // === Integration tests (require jj installed) ===

    fn jj_available() -> bool {
        Command::new("jj").arg("--version").output().is_ok_and(|o| o.status.success())
    }

    #[test]
    fn test_jj_refresh_detects_modified_file() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "initial\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "modified\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(!result.files.is_empty(), "should detect changed file");
        assert!(!result.lines.is_empty(), "should produce diff lines");

        // With from_rev=@- (no remote), base==head so all @→@ changes are Staged
        let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
        assert!(has_staged, "modified lines should have Staged source (current commit)");
        let header = &result.lines[0];
        assert_eq!(header.source, LineSource::FileHeader, "first line should be file header");
        assert!(!header.content.contains("(deleted)"),
            "modified file should not have deletion header, got: {}", header.content);
    }

    #[test]
    fn test_jj_refresh_detects_new_file() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("existing.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("new_file.txt"), "new content\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(!result.files.is_empty(), "should detect new file");
        let has_new_content = result.lines.iter().any(|l| l.content.contains("new content"));
        assert!(has_new_content, "should contain new file content in diff lines");
    }

    #[test]
    fn test_jj_comparison_context() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let ctx = vcs.comparison_context().unwrap();

        assert!(!ctx.to_label.is_empty(), "should have a to label");
        assert!(!ctx.from_label.is_empty(), "should have a from label");

        let base_id = vcs.base_identifier().unwrap();
        assert!(!base_id.is_empty(), "should have a base identifier");
    }

    #[test]
    fn test_jj_base_file_bytes() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "original\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "changed\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let base = vcs.base_file_bytes("file.txt").unwrap();
        assert_eq!(base.unwrap(), b"original\n");

        let working = vcs.working_file_bytes("file.txt").unwrap();
        assert_eq!(working.unwrap(), b"changed\n");
    }

    #[test]
    fn test_jj_refresh_detects_deleted_file() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("doomed.txt"), "goodbye\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::remove_file(repo.join("doomed.txt")).unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(!result.files.is_empty(), "should detect deleted file");
        let header = &result.lines[0];
        assert!(header.content.contains("(deleted)"),
            "deleted file should have deletion header, got: {}", header.content);
        // With from_rev=@- (no remote), base==head so deletions in @ are DeletedCommitted
        let has_deleted_source = result.lines.iter().any(|l| l.source == LineSource::DeletedCommitted);
        assert!(has_deleted_source, "deleted file lines should have DeletedCommitted source");
    }

    #[test]
    fn test_jj_single_file_diff_handles_rename() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("original.txt"), "line1\nline2\nline3\nline4\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::rename(repo.join("original.txt"), repo.join("renamed.txt")).unwrap();
        std::fs::write(repo.join("renamed.txt"), "line1\nline2\nline3\nmodified\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let diff = vcs.single_file_diff("renamed.txt");
        assert!(diff.is_some(), "should produce a diff for renamed file");

        let diff = diff.unwrap();
        let header = &diff.lines[0];
        assert!(
            header.content.contains("original.txt"),
            "rename header should reference old filename, got: {}",
            header.content
        );
    }

    #[test]
    fn test_jj_get_repo_root() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();

        let root = get_repo_root(repo).unwrap();
        // Canonicalize both to handle /tmp vs /private/tmp on macOS
        let expected = repo.canonicalize().unwrap();
        let actual = root.canonicalize().unwrap();
        assert_eq!(actual, expected);
    }

    #[test]
    fn test_jj_refresh_returns_base_label_with_bookmark() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "my-base", "-r", "@-"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "changed\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        let base_label = result.base_label.expect("should have base_label");
        assert!(base_label.contains("(my-base)"),
            "label should contain bookmark in parens, got: {base_label}");
        assert!(base_label.ends_with(')'),
            "label should end with ), got: {base_label}");
    }

    #[test]
    fn test_jj_refresh_returns_base_label_as_change_id_without_bookmark() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "changed\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        let base_label = result.base_label.expect("should have base_label");
        assert!(!base_label.is_empty());
        assert!(result.base_identifier.starts_with(&base_label),
            "without bookmark, base_label should be a prefix of base_identifier: label={base_label}, id={}", result.base_identifier);
    }

    #[test]
    fn test_jj_bookmark_strips_tracking_marker() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "my-branch"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let label = vcs.rev_label("@");
        assert_eq!(label, "my-branch", "should strip trailing * from bookmark name");
    }

    #[test]
    fn test_jj_refresh_parallel_with_multiple_files() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        for i in 0..6 {
            std::fs::write(repo.join(format!("file{i}.txt")), "initial\n").unwrap();
        }
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        for i in 0..6 {
            std::fs::write(repo.join(format!("file{i}.txt")), format!("modified {i}\n")).unwrap();
        }

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert_eq!(result.files.len(), 6, "should detect all 6 changed files");
        assert!(!result.base_identifier.is_empty());
    }

    #[test]
    fn test_jj_rev_metadata_no_snapshot_with_bookmark() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "test-bm", "-r", "@-"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let (change_id, label) = vcs.rev_metadata_no_snapshot("@-");

        assert!(!change_id.is_empty());
        assert!(label.contains("(test-bm)"),
            "label should contain bookmark in parens, got: {label}");
        assert!(label.ends_with(')'),
            "label should end with ), got: {label}");
    }

    #[test]
    fn test_jj_rev_metadata_no_snapshot_without_bookmark() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();

        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let (change_id, label) = vcs.rev_metadata_no_snapshot("@-");

        assert!(!change_id.is_empty());
        assert!(change_id.starts_with(&label),
            "without bookmark, label should be shortest prefix of change_id: label={label}, id={change_id}");
        assert!(label.len() >= 4, "shortest ID should be at least 4 chars, got: {label}");
    }

    // === resolve_base_rev tests ===

    #[test]
    fn test_resolve_base_rev_fallback_without_remote() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();
        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();

        let rev = resolve_base_rev(repo);
        assert_eq!(rev, "@-", "should fall back to @- when no remote tracking bookmarks");
    }

    #[test]
    fn test_resolve_base_rev_uses_trunk_with_remote() {
        if !jj_available() { return; }

        // Create a bare git repo as the "remote"
        let remote_dir = tempfile::TempDir::new().unwrap();
        Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();

        // Create a jj repo and add the remote
        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();
        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
        let remote_path = remote_dir.path().to_string_lossy().to_string();
        Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();

        let rev = resolve_base_rev(repo);
        assert_eq!(rev, "trunk()", "should use trunk() when remote tracking bookmarks exist");
    }

    // === Stack coloring tests ===

    /// Helper: create a jj repo with a remote "origin" and push main.
    /// Returns (repo_tempdir, remote_tempdir) — keep both alive.
    fn setup_repo_with_remote() -> (tempfile::TempDir, tempfile::TempDir) {
        let remote_dir = tempfile::TempDir::new().unwrap();
        Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();
        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("base.txt"), "trunk content\n").unwrap();
        Command::new("jj").args(["commit", "-m", "base"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
        let remote_path = remote_dir.path().to_string_lossy().to_string();
        Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();

        (temp, remote_dir)
    }

    #[test]
    fn test_jj_stack_coloring_two_commits() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // First stack commit: add a file
        std::fs::write(repo.join("stack.txt"), "from earlier commit\n").unwrap();
        Command::new("jj").args(["commit", "-m", "stack commit 1"]).current_dir(repo).output().unwrap();

        // Current commit (@): modify the file
        std::fs::write(repo.join("stack.txt"), "from earlier commit\nfrom current commit\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        assert_eq!(vcs.from_rev, "trunk()");

        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
        let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
        assert!(has_committed, "earlier stack commit lines should be Committed (teal)");
        assert!(has_staged, "current commit lines should be Staged (green)");
    }

    #[test]
    fn test_jj_single_commit_above_trunk_all_staged() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Only one commit above trunk — everything should be Staged
        std::fs::write(repo.join("feature.txt"), "new feature\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        assert_eq!(vcs.from_rev, "trunk()");

        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(!result.files.is_empty(), "should detect the new file");
        let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
        let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
        assert!(has_staged, "single commit above trunk: all additions should be Staged");
        assert!(!has_committed, "single commit above trunk: no lines should be Committed");
    }

    // === Stack tip resolution tests ===

    #[test]
    fn test_resolve_stack_tip_at_tip() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // @ is the tip — one commit above trunk, nothing above @
        std::fs::write(repo.join("feature.txt"), "content\n").unwrap();

        let tip = resolve_stack_tip(repo, "trunk()", &Arc::new(AtomicBool::new(false)));
        assert!(tip.is_none(), "should return None when @ is already the tip");
    }

    #[test]
    fn test_resolve_stack_tip_mid_stack() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Build 3-commit stack: commit1 → commit2 → commit3 (tip)
        std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "commit2\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "commit3\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();

        // Move @ back to commit2 (mid-stack)
        Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();

        let tip = resolve_stack_tip(repo, "trunk()", &Arc::new(AtomicBool::new(false)));
        assert!(tip.is_some(), "should return a tip when @ is mid-stack");
        let tip = tip.unwrap();
        assert_eq!(tip.head_count, 1, "linear stack should have 1 head");
        assert!(!tip.change_id.is_empty());
    }

    #[test]
    fn test_resolve_stack_tip_without_trunk() {
        if !jj_available() { return; }

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();
        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();

        let tip = resolve_stack_tip(repo, "@-", &Arc::new(AtomicBool::new(false)));
        assert!(tip.is_none(), "should return None when from_rev is not trunk()");
    }

    #[test]
    fn test_resolve_stack_tip_branching() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Linear base: commit1
        std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();

        // Branch: create commit2a on one branch
        std::fs::write(repo.join("branch_a.txt"), "branch a\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2a"]).current_dir(repo).output().unwrap();

        // Go back to commit1 and create commit2b (a second head)
        // After commit2a: @=empty, @-=commit2a, @--=commit1
        Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["new"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("branch_b.txt"), "branch b\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2b"]).current_dir(repo).output().unwrap();

        // Move @ to commit1 (below the fork)
        // After commit2b: @=empty, @-=commit2b, @--=commit1
        Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();

        let tip = resolve_stack_tip(repo, "trunk()", &Arc::new(AtomicBool::new(false)));
        assert!(tip.is_some(), "should detect stack tip when @ is below a fork");
        let tip = tip.unwrap();
        assert_eq!(tip.head_count, 2, "branching stack should have 2 heads");
    }

    #[test]
    fn test_stack_position_mid_stack() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Build 3-commit stack
        std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "commit2\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
        std::fs::write(repo.join("file.txt"), "commit3\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();

        // Move @ to commit2 (position 2 of 3 mutable commits, plus the empty @)
        Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();

        let tip = resolve_stack_tip(repo, "trunk()", &Arc::new(AtomicBool::new(false))).expect("should have a tip");
        let (current, total) = compute_stack_position(repo, &tip.change_id)
            .expect("should compute position");

        assert!(current >= 1 && current <= total,
            "current ({current}) should be within 1..={total}");
        assert!(total >= 3, "total ({total}) should be at least 3 commits");
    }

    #[test]
    fn test_jj_midstack_coloring() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Commit 1: add file
        std::fs::write(repo.join("file.txt"), "line1\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();

        // Commit 2: modify file
        std::fs::write(repo.join("file.txt"), "line1\nline2\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();

        // Commit 3: modify file further
        std::fs::write(repo.join("file.txt"), "line1\nline2\nline3\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();

        // Move @ to commit2 (mid-stack): @=empty, @-=commit3, @--=commit2
        Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
        let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
        let has_unstaged = result.lines.iter().any(|l| l.source == LineSource::Unstaged);

        assert!(has_committed, "earlier stack commits should produce Committed (teal)");
        assert!(has_staged, "current commit should produce Staged (green)");
        assert!(has_unstaged, "later stack commits should produce Unstaged (yellow)");

        assert!(result.stack_position.is_some(), "mid-stack should have stack_position");
        let pos = result.stack_position.unwrap();
        assert_eq!(pos.head_count, 1, "linear stack should have 1 head");
    }

    #[test]
    fn test_jj_midstack_later_only_file() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Commit 1: add base file
        std::fs::write(repo.join("base.txt"), "base stuff\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();

        // Commit 2 (will be @): add something
        std::fs::write(repo.join("current.txt"), "current\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();

        // Commit 3: add a file only in the later commit
        std::fs::write(repo.join("later.txt"), "later only\n").unwrap();
        Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();

        // Move @ to commit2
        Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        // later.txt should appear with Unstaged lines (only changed above @)
        let later_file = result.files.iter().find(|f| {
            f.lines.first().is_some_and(|l| l.content.contains("later.txt"))
        });
        assert!(later_file.is_some(), "later.txt should be in diff (file only in later commit)");

        let later_lines: Vec<_> = later_file.unwrap().lines.iter()
            .filter(|l| l.source == LineSource::Unstaged)
            .collect();
        assert!(!later_lines.is_empty(),
            "later.txt content should be Unstaged (only changed in later commit)");
    }

    #[test]
    fn test_jj_at_tip_no_stack_position() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let repo = temp.path();

        // Single commit above trunk — @ is at the tip
        std::fs::write(repo.join("feature.txt"), "content\n").unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(result.stack_position.is_none(),
            "stack_position should be None when @ is at the tip");
    }

    #[test]
    fn test_jj_trunk_deletion_coloring() {
        if !jj_available() { return; }

        let remote_dir = tempfile::TempDir::new().unwrap();
        Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();

        let temp = tempfile::TempDir::new().unwrap();
        let repo = temp.path();
        Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();

        // File exists at trunk
        std::fs::write(repo.join("doomed.txt"), "will be deleted\n").unwrap();
        Command::new("jj").args(["commit", "-m", "base"]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
        let remote_path = remote_dir.path().to_string_lossy().to_string();
        Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
        Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();

        // Current commit (@): delete the file
        std::fs::remove_file(repo.join("doomed.txt")).unwrap();

        let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
        assert_eq!(vcs.from_rev, "trunk()");

        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.refresh(&cancel).unwrap();

        assert!(!result.files.is_empty(), "should detect deleted file");
        let header = &result.lines[0];
        assert!(header.content.contains("(deleted)"),
            "deleted file should have deletion header, got: {}", header.content);
        let has_deleted = result.lines.iter().any(|l| l.source == LineSource::DeletedCommitted);
        assert!(has_deleted, "file deleted in current commit should have DeletedCommitted source");
    }

    // === parse_rev_metadata unit tests ===

    #[test]
    fn test_parse_rev_metadata_bookmark_with_shortest_id() {
        let (id, label) = parse_rev_metadata("main\0knmqypts1234\0knmq");
        assert_eq!(id, "knmqypts1234");
        assert_eq!(label, "knmq (main)");
    }

    #[test]
    fn test_parse_rev_metadata_no_bookmark() {
        let (id, label) = parse_rev_metadata("\0knmqypts1234\0knmq");
        assert_eq!(id, "knmqypts1234");
        assert_eq!(label, "knmq", "no bookmark → shortest change_id");
    }

    #[test]
    fn test_parse_rev_metadata_tracking_marker_with_shortest_id() {
        let (id, label) = parse_rev_metadata("feat*\0abcd1234efgh\0abcd");
        assert_eq!(id, "abcd1234efgh");
        assert_eq!(label, "abcd (feat)");
    }

    #[test]
    fn test_parse_rev_metadata_two_field_fallback() {
        let (id, label) = parse_rev_metadata("main\0knmqypts1234");
        assert_eq!(id, "knmqypts1234");
        assert_eq!(label, "main", "2-field format falls back to bookmark only");
    }

    #[test]
    fn test_parse_rev_metadata_multiple_bookmarks() {
        let (id, label) = parse_rev_metadata("b1 b2\0xvryypywztmm\0xvry");
        assert_eq!(id, "xvryypywztmm");
        assert_eq!(label, "xvry (b1 b2)");
    }

    #[test]
    fn test_parse_rev_metadata_no_delimiter() {
        let (id, label) = parse_rev_metadata("rawvalue");
        assert_eq!(id, "rawvalue");
        assert_eq!(label, "rawvalue");
    }

    // === mark_bookmark_provenance tests ===

    fn make_file_diff(lines: Vec<crate::diff::DiffLine>) -> crate::diff::FileDiff {
        crate::diff::FileDiff::new(lines)
    }

    #[test]
    fn test_mark_bookmark_provenance_file_in_bookmark() {
        let lines = vec![
            crate::diff::DiffLine::new(LineSource::Committed, "added".to_string(), '+', None),
            crate::diff::DiffLine::new(LineSource::DeletedBase, "removed".to_string(), '-', None),
            crate::diff::DiffLine::new(LineSource::Staged, "staged".to_string(), '+', None),
            crate::diff::DiffLine::new(LineSource::Base, "context".to_string(), ' ', None),
        ];
        let mut fd = make_file_diff(lines);

        mark_bookmark_provenance(&mut fd, true);

        assert_eq!(fd.lines[0].in_current_bookmark, Some(true),
            "Committed lines in a bookmark file should be marked true");
        assert_eq!(fd.lines[1].in_current_bookmark, Some(true),
            "DeletedBase lines in a bookmark file should be marked true");
        assert_eq!(fd.lines[2].in_current_bookmark, Some(true),
            "Staged lines in a bookmark file should be marked true");
        assert_eq!(fd.lines[3].in_current_bookmark, Some(false),
            "Base context lines are never in current bookmark");
    }

    #[test]
    fn test_mark_bookmark_provenance_file_not_in_bookmark() {
        let lines = vec![
            crate::diff::DiffLine::new(LineSource::Committed, "added".to_string(), '+', None),
            crate::diff::DiffLine::new(LineSource::DeletedBase, "removed".to_string(), '-', None),
            crate::diff::DiffLine::new(LineSource::Staged, "staged".to_string(), '+', None),
            crate::diff::DiffLine::new(LineSource::Base, "context".to_string(), ' ', None),
        ];
        let mut fd = make_file_diff(lines);

        mark_bookmark_provenance(&mut fd, false);

        assert_eq!(fd.lines[0].in_current_bookmark, Some(false),
            "Committed lines NOT in bookmark file should be marked false");
        assert_eq!(fd.lines[1].in_current_bookmark, Some(false),
            "DeletedBase lines NOT in bookmark file should be marked false");
        assert_eq!(fd.lines[2].in_current_bookmark, Some(false),
            "Staged lines NOT in bookmark file should be marked false");
        assert_eq!(fd.lines[3].in_current_bookmark, Some(false),
            "Base context lines are never in current bookmark");
    }

    #[test]
    fn test_mark_bookmark_provenance_base_with_change_source() {
        let mut line = crate::diff::DiffLine::new(LineSource::Base, "modified".to_string(), ' ', None);
        line.change_source = Some(LineSource::Committed);
        let mut fd = make_file_diff(vec![line]);

        mark_bookmark_provenance(&mut fd, true);
        assert_eq!(fd.lines[0].in_current_bookmark, Some(true),
            "Base with change_source in bookmark file should be marked true");

        let mut line2 = crate::diff::DiffLine::new(LineSource::Base, "modified".to_string(), ' ', None);
        line2.change_source = Some(LineSource::Committed);
        let mut fd2 = make_file_diff(vec![line2]);

        mark_bookmark_provenance(&mut fd2, false);
        assert_eq!(fd2.lines[0].in_current_bookmark, Some(false),
            "Base with change_source NOT in bookmark file should be marked false");
    }

    /// Verify the boundary revset format includes remote_bookmarks() so that
    /// stacks with only remote-tracking bookmarks for pushed segments are handled.
    #[test]
    fn test_boundary_revset_includes_remote_bookmarks() {
        let change_id = "abc123def456";
        let revset = format!(
            "latest((trunk()..\"{}\"-) & (bookmarks() | remote_bookmarks()))",
            change_id
        );
        assert!(revset.contains("remote_bookmarks()"),
            "Boundary revset must include remote_bookmarks() for pushed bookmark segments");
        assert!(revset.contains(&format!("\"{}\"", change_id)),
            "Boundary revset must reference the current bookmark's change ID");
    }

    /// `try_recover` with a pre-set cancel flag must bail before spawning jj —
    /// vcs-runner's contract is "If `cancel` is already set before spawn,
    /// returns `Cancelled` without starting the child." This is the cheap
    /// short-circuit branchdiff relies on when the user quits during a
    /// queued recovery.
    #[test]
    fn try_recover_short_circuits_on_preset_cancel() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();

        let cancel = Arc::new(AtomicBool::new(true));
        let result = vcs.try_recover(crate::update::RecoveryAction::JjUpdateStale, &cancel);
        assert!(result.is_err(), "pre-set cancel must abort recovery");
        let msg = format!("{:#}", result.unwrap_err()).to_lowercase();
        assert!(
            msg.contains("cancel"),
            "error must surface as cancellation, got: {msg:?}"
        );
    }

    /// Counterpart: with cancel clear, try_recover on a non-stale repo should
    /// succeed (jj's update-stale is a no-op when nothing's stale). This
    /// guards against accidentally short-circuiting on the wrong flag state.
    #[test]
    fn try_recover_succeeds_when_cancel_clear() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();

        let cancel = Arc::new(AtomicBool::new(false));
        let result = vcs.try_recover(crate::update::RecoveryAction::JjUpdateStale, &cancel);
        assert!(result.is_ok(), "non-stale repo should accept update-stale silently, got: {result:?}");
    }

    /// `refresh` with a pre-set cancel flag must bail quickly without doing
    /// the full work. Pre-set means the user hit `q` between scheduling and
    /// the spawn thread starting — we want a fast, clean exit, not a hang.
    #[test]
    fn refresh_short_circuits_on_preset_cancel() {
        if !jj_available() { return; }

        let (temp, _remote) = setup_repo_with_remote();
        std::fs::write(temp.path().join("feature.txt"), "new\n").unwrap();
        let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();

        let cancel = Arc::new(AtomicBool::new(true));
        let start = std::time::Instant::now();
        let result = vcs.refresh(&cancel);
        let elapsed = start.elapsed();

        assert!(result.is_err(), "pre-set cancel should produce an Err");
        // Should be fast — we're checking the cancel-propagation contract,
        // not measuring perf. A 5s ceiling catches a regression where
        // cancel doesn't propagate at all, without flaking on slow CI.
        assert!(
            elapsed < std::time::Duration::from_secs(5),
            "refresh with pre-set cancel took {elapsed:?} — cancel isn't propagating"
        );
    }
}
