//! ScrollbackEntry - wraps a block with display state.

use std::cell::{Ref, RefCell};
use std::path::{Path, PathBuf};

use chrono::{DateTime, Local};

use super::block::{BlockContent, RenderBlock};
use super::types::{BlockContext, BlockOutput, DisplayMode, RenderedBlockOutput};
use crate::appearance::AppearanceConfig;
use crate::theme::{ThemeKind, cache as theme_cache};

#[derive(Debug, Clone)]
struct CachedOutput {
    width: u16,
    raw: bool,
    theme: ThemeKind,
    is_selected: bool,
    cwd: Option<PathBuf>,
    rendered: RenderedBlockOutput,
}

/// Cached truncated-mode height: `(width, raw, theme, cwd, height)`.
///
/// Computing the truncated-mode height requires calling `block.output()` with
/// the display mode forced to `Truncated`, which for Edit blocks triggers full
/// syntect syntax highlighting and for Markdown blocks triggers full word-wrap.
/// During heavy streaming on a busy subagent, the layout cache is invalidated
/// every time a new block is pushed, so this height is recomputed for every
/// entry on every redraw without a per-entry cache. We only need the line
/// count, so this caches just the resulting `u16` height. `cwd` is keyed
/// because Expanded/Truncated Edit/Read header wrap can change absolute↔relative.
type CachedTruncatedHeight = (u16, bool, ThemeKind, Option<PathBuf>, u16);

/// Unique identifier for a scrollback entry.
///
/// EntryIds are stable across mutations - they won't become invalid if other
/// entries are added or removed. Use this for external handles to entries
/// (e.g., streaming tasks that need to push chunks to a specific block).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EntryId(u64);

impl EntryId {
    /// Create a new EntryId with a specific value.
    ///
    /// Note: For production use, prefer getting EntryId from `ScrollbackState::push()`
    /// which assigns IDs automatically. This is mainly for placeholders/testing.
    pub fn new(id: u64) -> Self {
        Self(id)
    }

    /// Get the raw ID value.
    pub fn value(self) -> u64 {
        self.0
    }
}

/// A scrollback entry: block content + display state.
#[derive(Debug, Clone)]
pub struct ScrollbackEntry {
    /// Unique identifier for this entry.
    pub id: EntryId,

    /// The block content.
    pub block: RenderBlock,

    /// Whether block is still running (for animation, auto-collapse).
    pub is_running: bool,

    /// Whether this entry is currently waiting on user input (permission
    /// prompt, ask-user-question, etc.). When true, the renderer replaces
    /// the wave "loading" animation with a pulsing-circle bullet to draw
    /// attention without implying active work.
    ///
    /// Maintained by `AgentView` from `permission_queue` and
    /// `question_view` state via `ScrollbackState::set_pending_user_input`.
    pub is_pending_user_input: bool,

    /// Current display mode.
    pub display_mode: DisplayMode,

    pub display_mode_pinned: bool,

    /// Raw mode: if true and block has_raw_mode(), render markdown as raw.
    pub raw: bool,

    /// Hook data attached to this entry (only meaningful for ToolCall blocks).
    pub hook_data: Option<super::blocks::tool::ToolCallHookData>,

    /// When this entry was created (local time).
    pub created_at: Option<DateTime<Local>>,

    /// When this entry finished running (monotonic). Used by the renderer
    /// to flash the accent briefly after completion.
    pub finished_at: Option<std::time::Instant>,

    /// Cached output and its render key.
    /// Interior-mutable so EntryRenderer (which holds `&self`) can populate and
    /// read the cache without &mut self.
    ///
    /// The `is_selected` key is only meaningful for blocks whose output varies
    /// by selection state (currently only `UserPrompt`). For all other blocks
    /// the stored value is always `false` regardless of actual selection,
    /// preventing unnecessary cache misses on selection changes. `cwd` is
    /// keyed so Expanded tool path paint (relative vs absolute) invalidates.
    cached_output: RefCell<Option<CachedOutput>>,

    /// Cached truncated-mode height. See [`CachedTruncatedHeight`] for why
    /// this needs its own cache separate from `cached_output`.
    ///
    /// Populated lazily by `ensure_truncated_height_cached`. Cleared by
    /// `invalidate_cache` together with `cached_output`.
    cached_truncated_height: RefCell<Option<CachedTruncatedHeight>>,

    /// Cached cheap height-estimate line count: `(content_width, lines)`. Lets a
    /// same-width rebuild reuse the estimate instead of re-cloning the block's
    /// source text. Cleared by `invalidate_cache`.
    cached_estimate_lines: RefCell<Option<(u16, u16)>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveOutputKind {
    Cached,
    Selected,
}

pub enum EffectiveOutputData<'a> {
    Borrowed(Ref<'a, BlockOutput>),
    Owned(BlockOutput),
}

impl EffectiveOutputData<'_> {
    #[allow(clippy::should_implement_trait)]
    pub fn as_ref(&self) -> &BlockOutput {
        match self {
            Self::Borrowed(output) => output,
            Self::Owned(output) => output,
        }
    }
}

pub struct EffectiveOutput<'a> {
    pub ctx: BlockContext,
    pub output: EffectiveOutputData<'a>,
    pub has_vpad: bool,
    pub kind: EffectiveOutputKind,
}

impl EffectiveOutput<'_> {
    pub fn output(&self) -> &BlockOutput {
        self.output.as_ref()
    }
}

impl ScrollbackEntry {
    /// Create a new entry with expanded display mode.
    ///
    /// Note: For production use, prefer `ScrollbackState::push()` which assigns
    /// the EntryId automatically. This constructor is mainly for testing.
    pub fn new(block: RenderBlock) -> Self {
        Self::with_id(EntryId(0), block)
    }

    /// Create a new entry with a specific ID.
    ///
    /// The display mode is set to the block's default (Expanded for most,
    /// Truncated for thinking blocks).
    pub fn with_id(id: EntryId, block: RenderBlock) -> Self {
        let display_mode = block.default_display_mode();
        Self {
            id,
            block,
            is_running: false,
            is_pending_user_input: false,
            display_mode,
            display_mode_pinned: false,
            raw: false,
            hook_data: None,
            created_at: Some(Local::now()),
            finished_at: None,
            cached_output: RefCell::new(None),
            cached_truncated_height: RefCell::new(None),
            cached_estimate_lines: RefCell::new(None),
        }
    }

    /// Create a new entry that is currently running.
    ///
    /// Note: For production use, prefer `ScrollbackState::push()` which assigns
    /// the EntryId automatically. This constructor is mainly for testing.
    pub fn running(block: RenderBlock) -> Self {
        Self::running_with_id(EntryId(0), block)
    }

    /// Create a new running entry with a specific ID.
    ///
    /// The display mode is set to the block's default (Expanded for most,
    /// Truncated for thinking blocks).
    pub fn running_with_id(id: EntryId, block: RenderBlock) -> Self {
        let display_mode = block.default_display_mode();
        Self {
            id,
            block,
            is_running: true,
            is_pending_user_input: false,
            display_mode,
            display_mode_pinned: false,
            raw: false,
            hook_data: None,
            created_at: Some(Local::now()),
            finished_at: None,
            cached_output: RefCell::new(None),
            cached_truncated_height: RefCell::new(None),
            cached_estimate_lines: RefCell::new(None),
        }
    }

    /// Set the display mode (builder pattern).
    pub fn with_display_mode(mut self, mode: DisplayMode) -> Self {
        self.display_mode = mode;
        self
    }

    /// Toggle raw mode if the block supports it.
    pub fn toggle_raw(&mut self) {
        if self.block.has_raw_mode() {
            self.raw = !self.raw;
            self.block.set_raw_mode(self.raw);
            self.invalidate_cache();
        }
    }

    /// Toggle between display modes.
    ///
    /// Most blocks toggle between Collapsed and Expanded.
    /// Some blocks (like thinking) cycle through 3 modes.
    pub fn toggle_fold(&mut self) {
        if self.is_foldable() {
            if self.block.is_foldable() {
                self.display_mode = self
                    .block
                    .next_fold_mode(self.display_mode, self.is_running);
            } else {
                // Block itself isn't foldable but hooks make it foldable:
                // toggle between Collapsed and Expanded.
                self.display_mode = match self.display_mode {
                    DisplayMode::Collapsed => DisplayMode::Expanded,
                    _ => DisplayMode::Collapsed,
                };
            }
            self.invalidate_cache();
        }
    }

    /// Get the current display mode.
    pub fn display_mode(&self) -> DisplayMode {
        self.display_mode
    }

    /// Set the display mode.
    pub fn set_display_mode(&mut self, mode: DisplayMode) {
        if self.display_mode != mode {
            self.display_mode = mode;
            self.invalidate_cache();
        }
    }

    /// Mark the block as completed (no longer running).
    ///
    /// Also clears `is_pending_user_input` since a completed tool cannot
    /// be waiting on a user response anymore.
    pub fn mark_completed(&mut self) {
        self.is_running = false;
        self.is_pending_user_input = false;
        self.invalidate_cache();
    }

    /// Invalidate cached output.
    pub fn invalidate_cache(&mut self) {
        *self.cached_output.borrow_mut() = None;
        *self.cached_truncated_height.borrow_mut() = None;
        *self.cached_estimate_lines.borrow_mut() = None;
    }

    /// Drop the heavyweight cached render output (and the block's internal
    /// rebuildable caches) while KEEPING the cheap height caches, so layout —
    /// entry heights, scroll position — is untouched. Re-rendering happens
    /// transparently if the entry scrolls back into view.
    ///
    /// Returns `true` when something was actually dropped (for sweep stats).
    pub(crate) fn evict_render_cache(&self) -> bool {
        let had_output = self.cached_output.borrow().is_some();
        if had_output {
            *self.cached_output.borrow_mut() = None;
        }
        self.block.evict_render_caches();
        had_output
    }

    /// Memoized cheap height-estimate line count for `content_width`, if cached.
    pub fn cached_estimate_lines(&self, content_width: u16) -> Option<u16> {
        self.cached_estimate_lines
            .borrow()
            .filter(|&(w, _)| w == content_width)
            .map(|(_, lines)| lines)
    }

    /// Store the cheap height-estimate line count for `content_width`.
    pub fn store_estimate_lines(&self, content_width: u16, lines: u16) {
        *self.cached_estimate_lines.borrow_mut() = Some((content_width, lines));
    }

    /// Whether this entry's laid-out output is cached. Lazy-layout tests use this
    /// to assert off-screen entries aren't rendered: `desired_height` populates
    /// the cache, the cheap estimate does not.
    #[cfg(test)]
    pub(crate) fn has_cached_output(&self) -> bool {
        self.cached_output.borrow().is_some()
    }

    /// Ensure the cache is populated for the given width/appearance/selection.
    ///
    /// This works with `&self` (via RefCell) so `EntryRenderer` can call it
    /// without needing `&mut self`. After calling this, use `cached_output_ref()`
    /// to borrow the output.
    pub fn ensure_cached(
        &self,
        width: u16,
        appearance: &AppearanceConfig,
        is_selected: bool,
        cwd: Option<&Path>,
    ) {
        // UserPrompt, ToolCall, Thinking, BgTask and Subagent vary their
        // output() based on is_selected — for all other blocks the output
        // is identical regardless of selection state. Normalize to false
        // for those blocks so selection changes don't thrash the cache.
        let effective_selected = is_selected
            && (self.block.is_user_prompt()
                || self.block.is_tool_call()
                || self.block.is_thinking()
                || self.block.is_bg_task()
                || self.block.is_subagent());

        let current_theme = theme_cache::current_kind();
        let cwd_key = cwd.map(|p| p.to_path_buf());
        {
            let cache = self.cached_output.borrow();
            if let Some(cached) = cache.as_ref()
                && cached.width == width
                && cached.raw == self.raw
                && cached.theme == current_theme
                && cached.is_selected == effective_selected
                && cached.cwd == cwd_key
            {
                return; // cache hit
            }
        }

        // Cache miss — regenerate
        let ctx = BlockContext {
            mode: self.display_mode,
            is_running: self.is_running,
            width,
            raw: self.raw,
            max_lines: None,
            appearance: appearance.clone(),
            is_selected: effective_selected,
            cwd: cwd_key.clone(),
        };
        let rendered = self.rendered_output_with_hooks(&ctx);
        *self.cached_output.borrow_mut() = Some(CachedOutput {
            width,
            raw: self.raw,
            theme: current_theme,
            is_selected: effective_selected,
            cwd: cwd_key,
            rendered,
        });
    }

    /// Borrow the cached output.
    ///
    /// Panics if `ensure_cached` was not called first for the current width.
    pub fn cached_output_ref(&self) -> Ref<'_, BlockOutput> {
        Ref::map(self.cached_output.borrow(), |opt| {
            &opt.as_ref()
                .expect("ensure_cached must be called first")
                .rendered
                .output
        })
    }

    pub(crate) fn cached_rendered_output_ref(&self) -> Ref<'_, RenderedBlockOutput> {
        Ref::map(self.cached_output.borrow(), |opt| {
            &opt.as_ref()
                .expect("ensure_cached must be called first")
                .rendered
        })
    }

    /// Ensure the truncated-mode height cache is populated, returning the height.
    ///
    /// Returns the line count (including vpad) the entry would occupy if
    /// rendered in `DisplayMode::Truncated`. Used by the layout cache to
    /// precompute sticky header heights for every entry.
    ///
    /// Without this cache, `block.output(&ctx)` runs uncached on every layout
    /// rebuild; for Edit blocks that triggers full syntect highlighting and
    /// for Markdown blocks a full word-wrap. During heavy subagent streaming
    /// the layout cache is invalidated on every new block (see
    /// `ScrollbackState::push`), so this would otherwise re-highlight every
    /// entry on every redraw.
    ///
    /// The cache key is `(content_width, raw, theme, cwd)`. `is_selected` is
    /// intentionally excluded because line count never depends on selection
    /// styling. Cleared together with `cached_output` by `invalidate_cache`.
    pub fn ensure_truncated_height_cached(
        &self,
        content_width: u16,
        appearance: &AppearanceConfig,
        cwd: Option<&Path>,
    ) -> u16 {
        let current_theme = theme_cache::current_kind();
        let cwd_key = cwd.map(|p| p.to_path_buf());
        {
            let cache = self.cached_truncated_height.borrow();
            if let Some(&(cached_width, cached_raw, cached_theme, ref cached_cwd, height)) =
                cache.as_ref()
                && cached_width == content_width
                && cached_raw == self.raw
                && cached_theme == current_theme
                && *cached_cwd == cwd_key
            {
                return height;
            }
        }

        // Force Truncated for sticky-header height; include cwd (header wrap).
        let ctx = self.context_with_mode(content_width, DisplayMode::Truncated, appearance, cwd);
        let output = self.block.output(&ctx);
        let has_vpad = self.block.has_vpad(&ctx);
        let content_height = output.len() as u16;
        let vpad = if has_vpad { 2 } else { 0 };
        let height = content_height + vpad;

        *self.cached_truncated_height.borrow_mut() =
            Some((content_width, self.raw, current_theme, cwd_key, height));
        height
    }

    pub fn effective_output(
        &self,
        width: u16,
        appearance: &AppearanceConfig,
        is_selected: bool,
        cwd: Option<&Path>,
    ) -> EffectiveOutput<'_> {
        let mut ctx = self.context(width, appearance, cwd);
        ctx.is_selected = is_selected;

        let has_vpad = self.block.has_vpad(&ctx);
        self.ensure_cached(width, appearance, is_selected, cwd);
        EffectiveOutput {
            ctx,
            output: EffectiveOutputData::Borrowed(self.cached_output_ref()),
            has_vpad,
            kind: if is_selected {
                EffectiveOutputKind::Selected
            } else {
                EffectiveOutputKind::Cached
            },
        }
    }

    /// Get the block output, using cache if valid.
    /// Note: cache doesn't track appearance - caller should invalidate on appearance change.
    pub fn output(
        &mut self,
        width: u16,
        appearance: &AppearanceConfig,
        cwd: Option<&Path>,
    ) -> &BlockOutput {
        self.ensure_cached(width, appearance, false, cwd);
        // We know the cache is populated, so unwrap through the RefCell
        // Safety: we just populated the cache above
        let cache = self.cached_output.get_mut();
        &cache.as_ref().unwrap().rendered.output
    }

    /// Get a BlockContext for this entry.
    pub fn context(
        &self,
        width: u16,
        appearance: &AppearanceConfig,
        cwd: Option<&Path>,
    ) -> BlockContext {
        BlockContext {
            mode: self.display_mode,
            is_running: self.is_running,
            width,
            raw: self.raw,
            max_lines: None,
            appearance: appearance.clone(),
            is_selected: false,
            cwd: cwd.map(|p| p.to_path_buf()),
        }
    }

    /// Whether this entry is foldable — considers both the block and attached hooks.
    pub fn is_foldable(&self) -> bool {
        self.block.is_foldable() || self.hook_data.as_ref().is_some_and(|hd| hd.has_content())
    }

    /// True for a thinking block hidden by the Appearance toggle. Takes the
    /// flag as a param so hot layout loops can hoist the cache read.
    pub fn is_hidden_thinking(&self, show_thinking: bool) -> bool {
        self.block.is_thinking() && !show_thinking
    }

    fn rendered_output_with_hooks(&self, ctx: &BlockContext) -> RenderedBlockOutput {
        let mut rendered = self.block.rendered_output(ctx);
        let output = &mut rendered.output;
        if let Some(ref hd) = self.hook_data {
            use super::blocks::tool::ToolCallBlock;
            use super::blocks::tool::hook::{
                render_hook_separator, render_hooks_detail, render_hooks_for_mode,
                render_hooks_inline_suffix,
            };
            let is_lifecycle = matches!(
                self.block,
                super::block::RenderBlock::ToolCall(ToolCallBlock::Lifecycle(_))
            );
            match ctx.mode {
                super::types::DisplayMode::Collapsed => {
                    // Append [hooks: N/M] to the first (header) line for all events
                    if let Some(suffix_spans) = render_hooks_inline_suffix(hd)
                        && let Some(first_line) = output.lines.first_mut()
                    {
                        first_line.content.spans.extend(suffix_spans);
                    }
                }
                _ => {
                    // Expanded: separator + separate sections
                    let pre = render_hooks_for_mode("pre_tool_use", &hd.pre_hooks, ctx.mode);
                    let post = render_hooks_for_mode("post_tool_use", &hd.post_hooks, ctx.mode);
                    let has_any = !pre.is_empty() || !post.is_empty() || !hd.lifecycle.is_empty();
                    // Lifecycle blocks already show the event name as the block header,
                    // so skip the separator (no tool output above) and the section header.
                    if has_any && !is_lifecycle {
                        output.lines.push(render_hook_separator());
                    }
                    output.lines.extend(pre);
                    output.lines.extend(post);
                    for (event_name, runs) in &hd.lifecycle {
                        if is_lifecycle {
                            output.lines.extend(render_hooks_detail(runs, ctx.mode));
                        } else {
                            output
                                .lines
                                .extend(render_hooks_for_mode(event_name, runs, ctx.mode));
                        }
                    }
                }
            }
        }
        rendered
    }

    /// Produce block output with hook lines injected (tool first, then hooks).
    pub fn output_with_hooks(&self, ctx: &BlockContext) -> BlockOutput {
        self.rendered_output_with_hooks(ctx).output
    }

    /// Get a BlockContext for this entry with a row budget.
    pub fn context_with_budget(
        &self,
        width: u16,
        max_lines: u16,
        appearance: &AppearanceConfig,
        cwd: Option<&Path>,
    ) -> BlockContext {
        BlockContext {
            mode: self.display_mode,
            is_running: self.is_running,
            width,
            raw: self.raw,
            max_lines: Some(max_lines),
            appearance: appearance.clone(),
            is_selected: false,
            cwd: cwd.map(|p| p.to_path_buf()),
        }
    }

    /// Get a BlockContext with a specific display mode override.
    ///
    /// This is used to compute heights for different display modes without
    /// modifying the entry's actual display_mode (avoiding cloning).
    pub fn context_with_mode(
        &self,
        width: u16,
        mode: DisplayMode,
        appearance: &AppearanceConfig,
        cwd: Option<&Path>,
    ) -> BlockContext {
        BlockContext {
            mode,
            is_running: self.is_running,
            width,
            raw: self.raw,
            max_lines: None,
            appearance: appearance.clone(),
            is_selected: false,
            cwd: cwd.map(|p| p.to_path_buf()),
        }
    }

    /// Get a BlockContext with both display mode override AND row budget.
    ///
    /// This is used for rendering sticky headers where we want:
    /// - Expanded content (not collapsed summary)
    /// - But truncated to a specific number of lines
    ///
    /// This avoids mutating the entry's display_mode during render.
    pub fn context_with_mode_and_budget(
        &self,
        width: u16,
        mode: DisplayMode,
        max_lines: u16,
        appearance: &AppearanceConfig,
        is_selected: bool,
        cwd: Option<&Path>,
    ) -> BlockContext {
        BlockContext {
            mode,
            is_running: self.is_running,
            width,
            raw: self.raw,
            max_lines: Some(max_lines),
            appearance: appearance.clone(),
            is_selected,
            cwd: cwd.map(|p| p.to_path_buf()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use ratatui::style::Color;

    #[test]
    fn test_entry_new() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
        assert!(!entry.is_running);
        assert!(matches!(entry.display_mode, DisplayMode::Expanded));
        assert!(!entry.display_mode_pinned);
    }

    #[test]
    fn test_entry_running() {
        let entry = ScrollbackEntry::running(RenderBlock::stub("test", Color::Blue));
        assert!(entry.is_running);
        assert!(!entry.display_mode_pinned);
    }

    #[test]
    fn test_entry_toggle_fold() {
        let mut entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
        assert!(matches!(entry.display_mode, DisplayMode::Expanded));

        entry.toggle_fold();
        assert!(matches!(entry.display_mode, DisplayMode::Collapsed));

        entry.toggle_fold();
        assert!(matches!(entry.display_mode, DisplayMode::Expanded));
    }

    #[test]
    fn test_entry_cache() {
        let mut entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
        let appearance = AppearanceConfig::default();

        let output1 = entry.output(80, &appearance, None);
        assert_eq!(output1.len(), 1);
        assert!(entry.cached_rendered_output_ref().boundaries.is_empty());

        let output2 = entry.output(80, &appearance, None);
        assert_eq!(output2.len(), 1);

        let output3 = entry.output(100, &appearance, None);
        assert_eq!(output3.len(), 1);
    }

    #[test]
    fn edit_boundary_sidecar_tracks_cached_output_mode() {
        let mut entry = ScrollbackEntry::new(RenderBlock::edit("   foo.rs", None));
        let appearance = AppearanceConfig::default();

        entry.set_display_mode(DisplayMode::Expanded);
        entry.ensure_cached(8, &appearance, false, None);
        {
            let rendered = entry.cached_rendered_output_ref();
            assert!(!rendered.output.lines.is_empty());
            assert!(!rendered.boundaries.is_empty());
        }
        entry.ensure_cached(8, &appearance, true, None);
        assert!(!entry.cached_rendered_output_ref().boundaries.is_empty());

        entry.set_display_mode(DisplayMode::Collapsed);
        entry.ensure_cached(8, &appearance, false, None);
        assert!(entry.cached_rendered_output_ref().boundaries.is_empty());
    }

    #[test]
    fn test_effective_output_uses_cached_branch_when_not_selected() {
        let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello"));
        let appearance = AppearanceConfig::default();
        let effective = entry.effective_output(80, &appearance, false, None);

        assert_eq!(effective.kind, EffectiveOutputKind::Cached);
        assert_eq!(effective.output().len(), 1);
    }

    #[test]
    fn test_effective_output_uses_selected_branch_when_selected() {
        let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello"));
        let appearance = AppearanceConfig::default();
        let effective = entry.effective_output(80, &appearance, true, None);

        assert_eq!(effective.kind, EffectiveOutputKind::Selected);
        assert!(effective.ctx.is_selected);
        assert_eq!(effective.output().len(), 1);
    }

    #[test]
    fn test_truncated_height_cache_populates_on_first_call() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
        let appearance = AppearanceConfig::default();

        assert!(entry.cached_truncated_height.borrow().is_none());

        let height = entry.ensure_truncated_height_cached(80, &appearance, None);
        assert!(height > 0);
        assert!(entry.cached_truncated_height.borrow().is_some());
    }

    #[test]
    fn test_truncated_height_cache_hits_when_key_unchanged() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
        let appearance = AppearanceConfig::default();

        let h1 = entry.ensure_truncated_height_cached(80, &appearance, None);
        let cached_before = entry.cached_truncated_height.borrow().clone();
        let h2 = entry.ensure_truncated_height_cached(80, &appearance, None);

        assert_eq!(h1, h2);
        // Cache pointer/value should be unchanged - no recompute happened.
        assert_eq!(*entry.cached_truncated_height.borrow(), cached_before);
    }

    #[test]
    fn test_truncated_height_cache_misses_on_width_change() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
        let appearance = AppearanceConfig::default();

        let _ = entry.ensure_truncated_height_cached(80, &appearance, None);
        let cached_at_80 = entry.cached_truncated_height.borrow().clone();
        let _ = entry.ensure_truncated_height_cached(40, &appearance, None);
        let cached_at_40 = entry.cached_truncated_height.borrow().clone();

        // Different width should overwrite the cache entry.
        assert_ne!(cached_at_80, cached_at_40);
        assert_eq!(cached_at_40.unwrap().0, 40);
    }

    #[test]
    fn test_invalidate_cache_clears_truncated_height_cache() {
        let mut entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
        let appearance = AppearanceConfig::default();

        let _ = entry.ensure_truncated_height_cached(80, &appearance, None);
        assert!(entry.cached_truncated_height.borrow().is_some());

        entry.invalidate_cache();
        assert!(entry.cached_truncated_height.borrow().is_none());
        assert!(entry.cached_output.borrow().is_none());
    }

    #[test]
    fn estimate_lines_cache_stores_keyed_on_width_and_clears_on_invalidate() {
        let mut entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));

        assert_eq!(entry.cached_estimate_lines(40), None);
        entry.store_estimate_lines(40, 7);
        assert_eq!(entry.cached_estimate_lines(40), Some(7));
        // Keyed on content width: a different width is a miss.
        assert_eq!(entry.cached_estimate_lines(41), None);

        // invalidate_cache clears the estimate too.
        entry.invalidate_cache();
        assert_eq!(entry.cached_estimate_lines(40), None);
    }

    #[test]
    fn test_entry_new_has_timestamp() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
        assert!(
            entry.created_at.is_some(),
            "ScrollbackEntry::new() should set created_at to Some"
        );
    }

    #[test]
    fn test_entry_running_has_timestamp() {
        let entry = ScrollbackEntry::running(RenderBlock::stub("test", Color::Blue));
        assert!(
            entry.created_at.is_some(),
            "ScrollbackEntry::running() should set created_at to Some"
        );
    }

    #[test]
    fn test_entry_with_display_mode_preserves_timestamp() {
        let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue))
            .with_display_mode(DisplayMode::Collapsed);
        assert!(
            entry.created_at.is_some(),
            "with_display_mode() should not clear created_at"
        );
        assert_eq!(entry.display_mode, DisplayMode::Collapsed);
    }
}
