use super::*;
use gpui::Pixels;
use rustc_hash::FxHasher;
use std::cell::RefCell;
use std::hash::{BuildHasherDefault, Hash, Hasher};
use std::num::NonZeroUsize;

pub(in crate::view) const MAX_LINES_FOR_SYNTAX_HIGHLIGHTING: usize = 4_000;
const MAX_CACHED_LINE_NUMBER: usize = 16_384;

/// Design width of a conflict diff-column line-number cell, shared by the div
/// path (`conflict_diff_line_number_cell`, `conflict_input_row_min_width`) and
/// the canvas path (`conflict_line_no_width`) so the gutter stays aligned.
///
/// Design units, not device pixels: read it through [`conflict_line_no_width`].
pub(in crate::view) const CONFLICT_DIFF_LINE_NO_WIDTH_PX: f32 = 38.0;

/// Design height of one conflict source/output row. The resolver's text is
/// shaped from `window.rem_size()`, which UI scale changes, so the row box has
/// to follow it -- a flat 20px row holds a 41px line box at 200% and the text
/// spills into the row below. Mirrors `diff_canvas::DIFF_ROW_HEIGHT_PX`.
pub(in crate::view) const CONFLICT_ROW_HEIGHT_PX: f32 = 20.0;

/// Design width of the accent/marker bar a conflict row paints at its left edge
/// to flag the active conflict. Mirrors `diff_canvas::DIFF_CHANGE_BAR_WIDTH_PX`.
pub(in crate::view) const CONFLICT_ROW_ACCENT_BAR_WIDTH_PX: f32 = 3.0;

/// Horizontal row padding, `px_2` on each side. The div path spends it through
/// `.px_2()` and the canvas path through `conflict_canvas::px_2`, both of which are
/// rem-derived and so already UI-scaled; this constant exists for the width sums
/// that have to account for it without laying it out.
pub(in crate::view) const CONFLICT_ROW_PADDING_X_PX: f32 = 8.0;

#[inline]
pub(in crate::view) fn conflict_scaled_px(value: f32, ui_scale_percent: u32) -> Pixels {
    crate::ui_scale::design_px_from_percent(value, ui_scale_percent)
}

#[inline]
pub(in crate::view) fn conflict_row_height(ui_scale_percent: u32) -> Pixels {
    conflict_scaled_px(CONFLICT_ROW_HEIGHT_PX, ui_scale_percent)
}

#[inline]
pub(in crate::view) fn conflict_line_no_width(ui_scale_percent: u32) -> Pixels {
    conflict_scaled_px(CONFLICT_DIFF_LINE_NO_WIDTH_PX, ui_scale_percent)
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(in crate::view) struct LruCacheMetrics {
    pub(in crate::view) hits: u64,
    pub(in crate::view) misses: u64,
    pub(in crate::view) evictions: u64,
    pub(in crate::view) clears: u64,
}

#[derive(Debug)]
pub(in crate::view) struct InstrumentedLruCache<
    K: std::hash::Hash + Eq,
    V,
    S: std::hash::BuildHasher = lru::DefaultHasher,
> {
    cache: lru::LruCache<K, V, S>,
    metrics: LruCacheMetrics,
}

impl<K: std::hash::Hash + Eq, V> InstrumentedLruCache<K, V> {
    pub(in crate::view) fn new(cap: usize) -> Self {
        Self {
            cache: lru::LruCache::new(non_zero_lru_capacity(cap)),
            metrics: LruCacheMetrics::default(),
        }
    }
}

impl<K: std::hash::Hash + Eq, V, S: std::hash::BuildHasher> InstrumentedLruCache<K, V, S> {
    pub(in crate::view) fn with_hasher(cap: usize, hash_builder: S) -> Self {
        Self {
            cache: lru::LruCache::with_hasher(non_zero_lru_capacity(cap), hash_builder),
            metrics: LruCacheMetrics::default(),
        }
    }

    pub(in crate::view) fn get(&mut self, key: &K) -> Option<&V> {
        let value = self.cache.get(key);
        if value.is_some() {
            self.metrics.hits = self.metrics.hits.saturating_add(1);
        } else {
            self.metrics.misses = self.metrics.misses.saturating_add(1);
        }
        value
    }

    #[cfg(test)]
    pub(in crate::view) fn peek(&self, key: &K) -> Option<&V> {
        self.cache.peek(key)
    }

    pub(in crate::view) fn put(&mut self, key: K, value: V) -> Option<V> {
        let will_evict =
            self.cache.peek(&key).is_none() && self.cache.len() >= self.cache.cap().get();
        let previous = self.cache.put(key, value);
        if will_evict {
            self.metrics.evictions = self.metrics.evictions.saturating_add(1);
        }
        previous
    }

    #[cfg(any(test, feature = "benchmarks"))]
    #[allow(dead_code)]
    pub(in crate::view) fn len(&self) -> usize {
        self.cache.len()
    }

    #[cfg(test)]
    pub(in crate::view) fn clear(&mut self) {
        self.cache.clear();
        self.metrics.clears = self.metrics.clears.saturating_add(1);
    }

    #[cfg(test)]
    pub(in crate::view) fn metrics(&self) -> LruCacheMetrics {
        self.metrics
    }
}

fn non_zero_lru_capacity(cap: usize) -> NonZeroUsize {
    NonZeroUsize::new(cap).expect("LRU cache capacity must be > 0")
}

pub(in crate::view) type LruCache<K, V> = InstrumentedLruCache<K, V>;
/// LRU cache backed by FxHasher for fast hashing of u64 keys (text layout caches).
pub(in crate::view) type FxLruCache<K, V> =
    InstrumentedLruCache<K, V, BuildHasherDefault<FxHasher>>;

pub(in crate::view) fn new_lru_cache<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> {
    InstrumentedLruCache::new(cap)
}

/// Create a new FxHasher-backed LRU cache with the given capacity.
pub(in crate::view) fn new_fx_lru_cache<K: std::hash::Hash + Eq, V>(
    cap: usize,
) -> FxLruCache<K, V> {
    InstrumentedLruCache::with_hasher(cap, BuildHasherDefault::default())
}

#[derive(Clone, Debug)]
pub(in crate::view) struct CommitFileRowPresentation {
    pub(in crate::view) label: SharedString,
    pub(in crate::view) visuals: CommitFileKindVisuals,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct CommitFileRowPresentationSignature {
    file_count: usize,
    total_path_bytes: usize,
    primary_hash: u64,
    secondary_hash: u64,
}

fn commit_file_row_presentation_signature(
    files: &[gitcomet_core::domain::CommitFileChange],
) -> CommitFileRowPresentationSignature {
    let mut primary = FxHasher::default();
    let mut secondary = FxHasher::default();
    0x9e37_79b9_7f4a_7c15u64.hash(&mut secondary);

    let mut total_path_bytes = 0usize;
    for (ix, file) in files.iter().enumerate() {
        let path_bytes = file.path.as_os_str().as_encoded_bytes();
        let kind_key = commit_file_visuals(file).kind_key;

        total_path_bytes = total_path_bytes.saturating_add(path_bytes.len());

        ix.hash(&mut primary);
        kind_key.hash(&mut primary);
        path_bytes.hash(&mut primary);

        kind_key.hash(&mut secondary);
        ix.hash(&mut secondary);
        path_bytes.len().hash(&mut secondary);
        path_bytes.hash(&mut secondary);
    }

    files.len().hash(&mut primary);
    total_path_bytes.hash(&mut primary);
    files.len().hash(&mut secondary);
    total_path_bytes.hash(&mut secondary);

    CommitFileRowPresentationSignature {
        file_count: files.len(),
        total_path_bytes,
        primary_hash: primary.finish(),
        secondary_hash: secondary.finish(),
    }
}

#[derive(Clone, Debug)]
struct CommitFileRowPresentationCacheEntry<K: Eq + Clone> {
    key: K,
    signature: CommitFileRowPresentationSignature,
    rows: Arc<[CommitFileRowPresentation]>,
}

#[derive(Clone, Debug)]
pub(in crate::view) struct CommitFileRowPresentationCache<K: Eq + Clone> {
    cached: Option<CommitFileRowPresentationCacheEntry<K>>,
}

impl<K: Eq + Clone> Default for CommitFileRowPresentationCache<K> {
    fn default() -> Self {
        Self { cached: None }
    }
}

impl<K: Eq + Clone> CommitFileRowPresentationCache<K> {
    fn build_entry(
        key: &K,
        files: &[gitcomet_core::domain::CommitFileChange],
        signature: CommitFileRowPresentationSignature,
    ) -> CommitFileRowPresentationCacheEntry<K> {
        let rows: Arc<[CommitFileRowPresentation]> = files
            .iter()
            .map(|file| CommitFileRowPresentation {
                label: super::path_display::path_display_shared_fast(&file.path),
                visuals: commit_file_visuals(file),
            })
            .collect::<Vec<_>>()
            .into();

        CommitFileRowPresentationCacheEntry {
            key: key.clone(),
            signature,
            rows,
        }
    }

    pub(in crate::view) fn rows_for(
        &mut self,
        key: &K,
        files: &[gitcomet_core::domain::CommitFileChange],
    ) -> Arc<[CommitFileRowPresentation]> {
        let signature = commit_file_row_presentation_signature(files);
        if let Some(reused_rows) = self.cached.as_ref().and_then(|entry| {
            if entry.key == *key || entry.signature == signature {
                Some(entry.rows.clone())
            } else {
                None
            }
        }) {
            self.cached = Some(CommitFileRowPresentationCacheEntry {
                key: key.clone(),
                signature,
                rows: reused_rows.clone(),
            });
            return reused_rows;
        }

        let entry = Self::build_entry(key, files, signature);
        let rows = entry.rows.clone();
        self.cached = Some(entry);
        rows
    }

    #[cfg(any(test, feature = "benchmarks"))]
    #[allow(dead_code)]
    pub(in crate::view) fn clear(&mut self) {
        self.cached = None;
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::view) enum CommitFileKindTone {
    Success,
    Warning,
    Danger,
    Accent,
}

impl CommitFileKindTone {
    #[inline]
    pub(in crate::view) fn color(self, theme: &AppTheme) -> gpui::Rgba {
        match self {
            Self::Success => theme.colors.status.success.foreground,
            Self::Warning => theme.colors.status.warning.foreground,
            Self::Danger => theme.colors.status.danger.foreground,
            Self::Accent => theme.colors.accent.foreground,
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::view) struct CommitFileKindVisuals {
    pub(in crate::view) icon: &'static str,
    pub(in crate::view) kind_key: u8,
    tone: CommitFileKindTone,
}

impl CommitFileKindVisuals {
    #[inline]
    pub(in crate::view) fn color(self, theme: &AppTheme) -> gpui::Rgba {
        self.tone.color(theme)
    }
}

/// Indexed by `FileStatusKind as usize`, so the order here is the enum's order,
/// not `kind_key`'s. `kind_key` is a separate, stable bucket index that callers
/// count by (`benchmarks/repo_history.rs` reads bucket 0 as "added") and that is
/// hashed into the presentation signature — change an entry's icon or tone
/// freely, but never its `kind_key`.
const COMMIT_FILE_KIND_VISUALS: [CommitFileKindVisuals; 6] = [
    // Untracked. Commit and range file lists cannot contain one, but a linked
    // worktree's list can, and it reads as an addition there — the same fold the
    // status pane, the submodule change rows and the diff title already apply.
    CommitFileKindVisuals {
        icon: "icons/plus.svg",
        kind_key: 4,
        tone: CommitFileKindTone::Success,
    },
    CommitFileKindVisuals {
        icon: "icons/pencil.svg",
        kind_key: 1,
        tone: CommitFileKindTone::Warning,
    },
    CommitFileKindVisuals {
        icon: "icons/plus.svg",
        kind_key: 0,
        tone: CommitFileKindTone::Success,
    },
    CommitFileKindVisuals {
        icon: "icons/minus.svg",
        kind_key: 2,
        tone: CommitFileKindTone::Danger,
    },
    CommitFileKindVisuals {
        icon: "icons/swap.svg",
        kind_key: 3,
        tone: CommitFileKindTone::Accent,
    },
    CommitFileKindVisuals {
        icon: "icons/warning.svg",
        kind_key: 5,
        tone: CommitFileKindTone::Danger,
    },
];

#[inline]
pub(in crate::view) const fn commit_file_kind_visuals(
    kind: FileStatusKind,
) -> CommitFileKindVisuals {
    COMMIT_FILE_KIND_VISUALS[kind as usize]
}

#[inline]
fn commit_file_visuals(file: &gitcomet_core::domain::CommitFileChange) -> CommitFileKindVisuals {
    let base = commit_file_kind_visuals(file.kind);
    if file.is_submodule {
        CommitFileKindVisuals {
            icon: "icons/box.svg",
            kind_key: base.kind_key | 0x40,
            tone: base.tone,
        }
    } else {
        base
    }
}

thread_local! {
    static LINE_NUMBER_STRINGS: RefCell<Vec<SharedString>> =
        RefCell::new(vec![SharedString::default()]);
}

fn line_number_string(n: Option<u32>) -> SharedString {
    let Some(n) = n else {
        return SharedString::default();
    };
    let ix = n as usize;
    if ix > MAX_CACHED_LINE_NUMBER {
        return n.to_string().into();
    }
    LINE_NUMBER_STRINGS.with(|cache| {
        let mut cache = cache.borrow_mut();
        if cache.len() <= ix {
            let start = cache.len();
            cache.reserve(ix + 1 - start);
            for v in start..=ix {
                cache.push(v.to_string().into());
            }
        }
        cache[ix].clone()
    })
}

mod blame;
mod canvas;
#[cfg(test)]
mod canvas_tests;
mod conflict_canvas;
mod conflict_resolver;
mod diff;
mod diff_canvas;
mod diff_text;
mod history;
mod history_canvas;
pub(in crate::view) mod history_graph_paint;
mod markdown_document;
mod markdown_flow_text;
pub(in crate::view) mod sidebar;
mod status;

#[cfg(feature = "benchmarks")]
pub(crate) mod benchmarks;

pub(in crate::view) use self::conflict_resolver::{
    resolved_output_gutter_width, resolved_output_line_no_width,
};
pub(in crate::view) use self::diff::{BlameRenderCtx, build_row_blame_paint};
pub(in crate::view) use self::diff_canvas::blame_gutter_row_canvas;
pub(in crate::view) use self::history::{
    MarkdownPreviewImageSource, MarkdownPreviewPictureSizes, MarkdownPreviewQuery,
    MarkdownPreviewRevealRequest, markdown_preview_alert_bar_color, markdown_preview_alert_label,
    markdown_preview_flow_image, markdown_preview_highlighted_text, markdown_preview_image_source,
    markdown_preview_inline_image, markdown_preview_marker_label, markdown_preview_reveal_offset_y,
    markdown_preview_row_background, markdown_preview_row_extent,
    markdown_preview_styled_row_with_query, worktree_markdown_preview_bar_color,
};
pub(in crate::view) use self::markdown_document::{
    MarkdownDocumentBlockCache, MarkdownDocumentBlockScrolls, MarkdownDocumentContext,
    render_markdown_document,
};
#[cfg(test)]
pub(in crate::view) use self::markdown_flow_text::{
    clear_markdown_selection_paint_log_for_tests, markdown_selection_paint_log_for_tests,
};
pub(in crate::view) use self::markdown_flow_text::{
    markdown_flow_painted_offset, markdown_flow_row_offset,
};
pub(in crate::view) use self::sidebar::active_workspace_paths_by_branch;
pub(in crate::view) use self::sidebar::listed_workspace_paths_by_branch;

#[cfg(any(test, feature = "benchmarks"))]
pub(in crate::view) use diff_text::has_pending_prepared_diff_syntax_chunk_builds_for_document;
pub(in crate::view) use diff_text::{
    BackgroundPreparedDiffSyntaxDocument, DiffSearchMatchEmphasis, DiffSyntaxBudget,
    DiffSyntaxEdit, DiffSyntaxLanguage, DiffSyntaxMode, LiveSyntaxDocument, LiveSyntaxSnapshot,
    LiveSyntaxSyncOutcome, PREPARED_DIFF_SYNTAX_DOCUMENT_MAX_TEXT_BYTES,
    PrepareDiffSyntaxDocumentResult, PreparedDiffSyntaxDocument, PreparedDiffSyntaxLine,
    PreparedDiffSyntaxReparseSeed, diff_syntax_language_for_code_fence_info,
    diff_syntax_language_for_path, diff_wrap_ranges_for_text,
    drain_completed_prepared_diff_syntax_chunk_builds,
    drain_completed_prepared_diff_syntax_chunk_builds_for_document,
    has_pending_prepared_diff_syntax_chunk_builds, inject_background_prepared_diff_syntax_document,
    live_syntax_document_supported, live_syntax_reparse,
    prepare_diff_syntax_document_in_background_text_with_reuse,
    prepare_diff_syntax_document_with_budget_reuse_text,
    prepared_diff_syntax_line_for_inline_diff_row, prepared_diff_syntax_line_for_one_based_line,
    prepared_diff_syntax_reparse_seed, query_highlight_colors,
    request_syntax_highlights_for_prepared_document_byte_range, resolved_output_line_text,
    syntax_highlights_for_line, whitespace_visible_line_text,
};

pub(in crate::view) use self::diff_canvas::{
    AnnotArea, DIFF_ANNOTATION_COLUMN_WIDTH_PX, DIFF_ANNOTATION_MAX_WIDTH_PX,
    DIFF_ANNOTATION_MIN_WIDTH_PX, DiffStageHover, DiffStageSlot, DiffTextWrapSlice,
    DiffWrapByteRange, diff_change_bar_width as diff_canvas_change_bar_width,
    diff_inline_text_start as diff_canvas_inline_text_start,
    diff_row_horizontal_padding as diff_canvas_row_horizontal_padding,
    diff_single_column_text_start as diff_canvas_single_column_text_start,
    diff_text_wrap_char_width as diff_canvas_text_wrap_char_width, is_streamable_diff_text,
    whitespace_visible_diff_offset_map,
};
#[cfg(test)]
pub(in crate::view) use self::diff_canvas::{
    DiffPaintRecord, clear_diff_paint_log_for_tests, diff_paint_log_for_tests,
};

#[cfg(test)]
pub(in crate::view) use diff_text::{
    PreparedDiffSyntaxParseMode, prepare_diff_syntax_document_in_background_text,
    prepared_diff_syntax_parse_mode, prepared_diff_syntax_source_version,
};

#[cfg(test)]
mod tests {
    use super::*;
    use gitcomet_core::domain::{CommitFileChange, FileStatusKind};
    use std::path::PathBuf;
    use std::sync::Arc;

    fn reset_line_number_string_cache() {
        LINE_NUMBER_STRINGS.with(|cache| {
            let mut cache = cache.borrow_mut();
            cache.clear();
            cache.push(SharedString::default());
        });
    }

    fn line_number_string_cache_len() -> usize {
        LINE_NUMBER_STRINGS.with(|cache| cache.borrow().len())
    }

    #[test]
    fn line_number_cache_does_not_grow_for_uncached_large_numbers() {
        reset_line_number_string_cache();
        assert_eq!(line_number_string_cache_len(), 1);

        assert_eq!(line_number_string(Some(8)), SharedString::from("8"));
        assert_eq!(line_number_string_cache_len(), 9);

        let uncached_line = (MAX_CACHED_LINE_NUMBER as u32).saturating_add(1);
        assert_eq!(
            line_number_string(Some(uncached_line)),
            uncached_line.to_string()
        );
        assert_eq!(line_number_string_cache_len(), 9);
    }

    #[test]
    fn line_number_cache_still_caches_small_numbers() {
        reset_line_number_string_cache();
        assert_eq!(line_number_string_cache_len(), 1);

        assert_eq!(line_number_string(Some(1)), SharedString::from("1"));
        assert_eq!(line_number_string(Some(3)), SharedString::from("3"));
        assert_eq!(line_number_string(Some(1)), SharedString::from("1"));
        assert_eq!(line_number_string_cache_len(), 4);
    }

    #[test]
    fn lru_cache_evicts_least_recently_used() {
        let mut cache: FxLruCache<u64, u64> = new_fx_lru_cache(8);
        for key in 0..8u64 {
            cache.put(key, key);
        }
        assert_eq!(cache.len(), 8);

        // Insert a 9th entry — should evict key 0 (LRU)
        cache.put(999, 999);
        assert_eq!(cache.len(), 8);
        assert!(cache.peek(&999).is_some());
        assert!(cache.peek(&0).is_none(), "LRU entry should be evicted");
        assert!(cache.peek(&7).is_some(), "MRU entry should remain");
    }

    #[test]
    fn lru_cache_promotes_on_get() {
        let mut cache: FxLruCache<u64, u64> = new_fx_lru_cache(4);
        for key in 0..4u64 {
            cache.put(key, key);
        }

        // Access key 0 to promote it to MRU
        assert_eq!(cache.get(&0), Some(&0));

        // Insert 4 more entries — key 0 should survive (was promoted)
        cache.put(10, 10);
        cache.put(11, 11);
        cache.put(12, 12);

        assert!(cache.peek(&0).is_some(), "promoted entry should survive");
        assert!(
            cache.peek(&1).is_none(),
            "unpromoted old entry should be evicted"
        );
    }

    #[test]
    fn lru_cache_metrics_track_hits_misses_evictions_and_clears() {
        let mut cache: FxLruCache<u64, u64> = new_fx_lru_cache(2);

        assert_eq!(cache.get(&1), None);
        assert_eq!(
            cache.metrics(),
            LruCacheMetrics {
                hits: 0,
                misses: 1,
                evictions: 0,
                clears: 0,
            }
        );

        cache.put(1, 10);
        cache.put(2, 20);
        assert_eq!(cache.get(&1), Some(&10));
        assert_eq!(
            cache.metrics(),
            LruCacheMetrics {
                hits: 1,
                misses: 1,
                evictions: 0,
                clears: 0,
            }
        );

        cache.put(3, 30);
        assert_eq!(
            cache.peek(&2),
            None,
            "least-recently used entry should evict"
        );
        assert_eq!(
            cache.metrics(),
            LruCacheMetrics {
                hits: 1,
                misses: 1,
                evictions: 1,
                clears: 0,
            }
        );

        cache.clear();
        assert_eq!(cache.len(), 0);
        assert_eq!(
            cache.metrics(),
            LruCacheMetrics {
                hits: 1,
                misses: 1,
                evictions: 1,
                clears: 1,
            }
        );
    }

    #[test]
    fn commit_file_row_presentation_cache_reuses_same_key_and_invalidates_on_new_key() {
        let mut cache: CommitFileRowPresentationCache<u64> =
            CommitFileRowPresentationCache::default();
        let files = vec![
            CommitFileChange {
                path: PathBuf::from("src/lib.rs"),
                kind: FileStatusKind::Modified,
                is_submodule: false,
                additions: None,
                deletions: None,
            },
            CommitFileChange {
                path: PathBuf::from("README.md"),
                kind: FileStatusKind::Added,
                is_submodule: false,
                additions: None,
                deletions: None,
            },
        ];

        let first = cache.rows_for(&7, &files);
        let reused = cache.rows_for(
            &7,
            &[CommitFileChange {
                path: PathBuf::from("should/not/appear.rs"),
                kind: FileStatusKind::Deleted,
                is_submodule: false,
                additions: None,
                deletions: None,
            }],
        );

        assert!(Arc::ptr_eq(&first, &reused));
        assert_eq!(
            first
                .iter()
                .map(|row| row.label.as_ref())
                .collect::<Vec<_>>(),
            vec!["src/lib.rs", "README.md"]
        );
        assert_eq!(
            first[0].visuals,
            commit_file_kind_visuals(FileStatusKind::Modified)
        );
        assert_eq!(
            first[1].visuals,
            commit_file_kind_visuals(FileStatusKind::Added)
        );

        let replacement = cache.rows_for(
            &8,
            &[CommitFileChange {
                path: PathBuf::from("docs/guide.md"),
                kind: FileStatusKind::Renamed,
                is_submodule: false,
                additions: None,
                deletions: None,
            }],
        );

        assert!(!Arc::ptr_eq(&first, &replacement));
        assert_eq!(
            replacement
                .iter()
                .map(|row| row.label.as_ref())
                .collect::<Vec<_>>(),
            vec!["docs/guide.md"]
        );
        assert_eq!(
            replacement[0].visuals,
            commit_file_kind_visuals(FileStatusKind::Renamed)
        );
    }

    /// A linked worktree's file list contains untracked files, which commit and
    /// range lists never do. The table slot for them used to hold a
    /// "shouldn't happen" question mark, which is what those rows rendered.
    #[test]
    fn untracked_files_read_as_additions() {
        let untracked = commit_file_kind_visuals(FileStatusKind::Untracked);
        assert_eq!(untracked.icon, "icons/plus.svg");
        assert_eq!(
            untracked.icon,
            commit_file_kind_visuals(FileStatusKind::Added).icon,
            "an untracked file reads as an addition, as it does everywhere else"
        );
    }

    /// `kind_key` is a separate ordering from the table's own: callers count into
    /// buckets by it (`benchmarks/repo_history.rs` reads bucket 0 as "added") and
    /// it is hashed into the row signature. Retoning an entry must never move it.
    #[test]
    fn kind_keys_are_stable_buckets() {
        assert_eq!(commit_file_kind_visuals(FileStatusKind::Added).kind_key, 0);
        assert_eq!(
            commit_file_kind_visuals(FileStatusKind::Modified).kind_key,
            1
        );
        assert_eq!(
            commit_file_kind_visuals(FileStatusKind::Deleted).kind_key,
            2
        );
        assert_eq!(
            commit_file_kind_visuals(FileStatusKind::Renamed).kind_key,
            3
        );
        assert_eq!(
            commit_file_kind_visuals(FileStatusKind::Untracked).kind_key,
            4
        );
        assert_eq!(
            commit_file_kind_visuals(FileStatusKind::Conflicted).kind_key,
            5
        );
    }

    /// `rows_for` returns cached rows when the *key* matches, without looking at
    /// the files. The worktree list keys on the scan revision, which does not
    /// move when a different worktree is selected, so the key has to carry the
    /// worktree too — otherwise one worktree shows another's files.
    #[test]
    fn a_key_that_does_not_move_with_the_selection_serves_stale_rows() {
        let mut cache: CommitFileRowPresentationCache<(u64, std::path::PathBuf)> =
            CommitFileRowPresentationCache::default();

        let first = cache.rows_for(
            &(1, PathBuf::from("/wt/a")),
            &[CommitFileChange {
                path: PathBuf::from("a.rs"),
                kind: FileStatusKind::Modified,
                is_submodule: false,
                additions: None,
                deletions: None,
            }],
        );
        assert_eq!(first[0].label.as_ref(), "a.rs");

        // Same revision, different worktree: the path in the key is what keeps
        // these apart.
        let second = cache.rows_for(
            &(1, PathBuf::from("/wt/b")),
            &[CommitFileChange {
                path: PathBuf::from("b.rs"),
                kind: FileStatusKind::Modified,
                is_submodule: false,
                additions: None,
                deletions: None,
            }],
        );
        assert_eq!(
            second[0].label.as_ref(),
            "b.rs",
            "a second worktree must not be served the first one's rows"
        );
    }

    #[test]
    fn commit_file_row_presentation_cache_reuses_identical_files_across_new_keys() {
        let mut cache: CommitFileRowPresentationCache<u64> =
            CommitFileRowPresentationCache::default();
        let files = vec![
            CommitFileChange {
                path: PathBuf::from("src/lib.rs"),
                kind: FileStatusKind::Modified,
                is_submodule: false,
                additions: None,
                deletions: None,
            },
            CommitFileChange {
                path: PathBuf::from("README.md"),
                kind: FileStatusKind::Added,
                is_submodule: false,
                additions: None,
                deletions: None,
            },
        ];

        let first = cache.rows_for(&7, &files);
        let reused = cache.rows_for(&8, &files);

        assert!(Arc::ptr_eq(&first, &reused));
        assert_eq!(
            reused
                .iter()
                .map(|row| row.label.as_ref())
                .collect::<Vec<_>>(),
            vec!["src/lib.rs", "README.md"]
        );
    }

    #[test]
    fn commit_file_row_presentation_cache_handles_empty_file_lists() {
        let mut cache: CommitFileRowPresentationCache<u64> =
            CommitFileRowPresentationCache::default();

        let first = cache.rows_for(&1, &[]);
        let second = cache.rows_for(&1, &[]);

        assert!(first.is_empty());
        assert!(Arc::ptr_eq(&first, &second));
    }

    /// The resolver's text is shaped from `window.rem_size()`, so its row box and
    /// line-number cell have to grow with UI scale too -- a flat 20px row holds a
    /// 41px line box at 200% and spills into the row below.
    #[test]
    fn conflict_row_geometry_scales_with_ui_scale() {
        for percent in [80, 100, 150, 200] {
            let factor = percent as f32 / 100.0;
            let height: f32 = conflict_row_height(percent).into();
            let line_no: f32 = conflict_line_no_width(percent).into();
            assert!(
                (height - CONFLICT_ROW_HEIGHT_PX * factor).abs() < 0.01,
                "row height at {percent}% should be {}, got {height}",
                CONFLICT_ROW_HEIGHT_PX * factor,
            );
            assert!(
                (line_no - CONFLICT_DIFF_LINE_NO_WIDTH_PX * factor).abs() < 0.01,
                "line-number width at {percent}% should be {}, got {line_no}",
                CONFLICT_DIFF_LINE_NO_WIDTH_PX * factor,
            );
        }

        // Strictly monotonic across the presets, so no two zoom levels collapse
        // onto the same geometry.
        let heights = crate::ui_scale::UI_SCALE_PRESETS
            .iter()
            .map(|percent| f32::from(conflict_row_height(*percent)))
            .collect::<Vec<_>>();
        assert!(
            heights.windows(2).all(|pair| pair[0] < pair[1]),
            "row heights should grow with every preset, got {heights:?}"
        );
    }

    /// The conflict rows and the diff rows are the same 20px design row; keeping the
    /// two helpers in agreement is what stops the resolver drifting away from the
    /// diff view it sits beside.
    #[test]
    fn conflict_row_height_matches_the_diff_row_height() {
        for percent in crate::ui_scale::UI_SCALE_PRESETS.iter().copied() {
            assert_eq!(
                conflict_row_height(percent),
                crate::view::panes::main::diff_row_height_for_ui_scale(percent),
                "conflict and diff rows disagree at {percent}%"
            );
        }
    }
}
