//! History cell for hook execution.
//!
//! Hooks are intentionally quieter than normal tool calls. A hook that starts and finishes
//! successfully without output should not leave a transcript artifact, and very fast hooks should
//! not flash in the viewport. This cell keeps that policy local by treating each hook run as a
//! small rendering state machine:
//!
//! 1. New runs begin hidden in `PendingReveal`.
//! 2. Runs that outlive the reveal delay become visible and may be coalesced with adjacent runs.
//! 3. Visible quiet successes linger briefly so they do not disappear in the same frame they were
//!    first drawn.
//! 4. Completed runs only persist when they have output or a non-success status.
use super::HistoryCell;
use super::plain_lines;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind;
use codex_app_server_protocol::HookRunStatus;
use codex_app_server_protocol::HookRunSummary;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::time::Duration;
use std::time::Instant;

#[derive(Debug)]
pub(crate) struct HookCell {
    /// Hook runs that are active, lingering, or have persistent output to render.
    runs: Vec<HookRunCell>,
    /// Mirrors the global animation setting so transcript rendering and viewport rendering agree.
    animations_enabled: bool,
}

/// Minimum runtime before a hook is allowed to draw.
///
/// Helps avoids a flash of text forwork that was effectively instant.
const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300);

/// Minimum time a quiet success remains on screen after becoming visible.
///
/// This pairs with `HOOK_RUN_REVEAL_DELAY`: once the user has seen a hook row, keep it stable long
/// enough to read instead of removing it immediately when the success event arrives.
const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);

const HOOK_OUTPUT_INDENT: &str = "  ";
const HOOK_OUTPUT_BODY_INDENT: &str = "    ";

#[derive(Debug)]
struct HookRunCell {
    /// Stable protocol id used to match begin/end updates for the same hook invocation.
    id: String,
    /// Hook event kind, kept outside `state` so a begin update can refresh metadata in place.
    event_name: HookEventName,
    /// Optional hook-supplied detail shown next to the running header.
    status_message: Option<String>,
    /// Rendering lifecycle for this run.
    state: HookRunState,
}

#[derive(Debug)]
enum HookRunState {
    /// A newly-started run that is active but deliberately hidden until `reveal_deadline`.
    PendingReveal {
        /// The original start time, used for spinner phase and grouping once revealed.
        start_time: Instant,
        /// First instant at which the run may become visible.
        reveal_deadline: Instant,
    },
    /// A run that survived the reveal delay and is currently shown as running.
    VisibleRunning {
        /// The original start time, used to keep animation timing stable across transitions.
        start_time: Instant,
        /// First instant the run was actually rendered, used by quiet-success linger.
        visible_since: Instant,
    },
    /// A visible run that completed successfully without output but is still lingering briefly.
    QuietLinger {
        /// The original start time, retained so the spinner does not jump during the linger frame.
        start_time: Instant,
        /// Instant after which the quiet success can be removed entirely.
        removal_deadline: Instant,
    },
    /// A completed run with output or a status worth preserving in history.
    Completed {
        /// Final protocol status for the hook invocation.
        status: HookRunStatus,
        /// Hook output entries rendered below the completed header.
        entries: Vec<HookOutputEntry>,
    },
}

#[derive(Debug, PartialEq, Eq)]
struct RunningHookGroupKey {
    event_name: HookEventName,
    status_message: Option<String>,
}

/// Accumulator for adjacent running hooks that can share one status line.
///
/// Grouping happens only while building display lines, the underlying runs stay separate so their
/// protocol ids and completion transitions remain independent.
struct RunningHookGroup {
    /// Shared event/status pair for every run in this display group.
    key: RunningHookGroupKey,
    /// Earliest start time in the group, so the combined spinner reflects the oldest work.
    start_time: Option<Instant>,
    /// Number of adjacent runs represented by the group line.
    count: usize,
}

impl HookCell {
    /// Creates a cell around a hook that has just started.
    fn new_active(run: HookRunSummary, animations_enabled: bool) -> Self {
        let mut cell = Self {
            runs: Vec::new(),
            animations_enabled,
        };
        cell.start_run(run);
        cell
    }

    /// Creates a cell around an already-completed hook from transcript/history data.
    fn new_completed(run: HookRunSummary, animations_enabled: bool) -> Self {
        let mut cell = Self {
            runs: Vec::new(),
            animations_enabled,
        };
        cell.add_completed_run(run);
        cell
    }

    pub(crate) fn is_empty(&self) -> bool {
        self.runs.is_empty()
    }

    /// Returns true while any run can still change due to an end event or timer.
    pub(crate) fn is_active(&self) -> bool {
        self.runs.iter().any(|run| run.state.is_active())
    }

    /// Completed hook cells are flushed out of the active slot once no timers remain.
    pub(crate) fn should_flush(&self) -> bool {
        !self.is_active() && !self.is_empty()
    }

    /// Returns whether this cell has at least one line worth drawing right now.
    pub(crate) fn should_render(&self) -> bool {
        self.runs.iter().any(|run| run.state.should_render())
    }

    /// Splits durable completed runs from ephemeral active-cell bookkeeping.
    ///
    /// Quiet successes are left behind so they can disappear from the active cell, while failures,
    /// blocked/stopped hooks, and hooks with emitted output become a persistent history cell.
    pub(crate) fn take_completed_persistent_runs(&mut self) -> Option<Self> {
        let mut completed = Vec::new();
        let mut remaining = Vec::new();
        for run in self.runs.drain(..) {
            if run.state.has_persistent_output() {
                completed.push(run);
            } else {
                remaining.push(run);
            }
        }
        self.runs = remaining;
        (!completed.is_empty()).then_some(Self {
            runs: completed,
            animations_enabled: self.animations_enabled,
        })
    }

    /// Used by callers that need to know whether the active cell currently occupies viewport space.
    pub(crate) fn has_visible_running_run(&self) -> bool {
        self.runs.iter().any(|run| run.state.is_running_visible())
    }

    /// Advances reveal/removal timers and reports whether rendering should be refreshed.
    pub(crate) fn advance_time(&mut self, now: Instant) -> bool {
        let old_len = self.runs.len();
        let mut changed = false;
        for run in &mut self.runs {
            changed |= run.state.reveal_if_due(now);
        }
        self.runs.retain(|run| !run.state.quiet_linger_expired(now));
        changed || self.runs.len() != old_len
    }

    /// Inserts or refreshes a started hook run.
    ///
    /// A duplicate begin event resets the reveal timer rather than adding a second row, because
    /// matching by id is the invariant that keeps begin/end events paired.
    pub(crate) fn start_run(&mut self, run: HookRunSummary) {
        let now = Instant::now();
        if let Some(existing) = self.runs.iter_mut().find(|existing| existing.id == run.id) {
            existing.event_name = run.event_name;
            existing.status_message = run.status_message;
            existing.state = HookRunState::pending(now);
            return;
        }
        self.runs.push(HookRunCell {
            id: run.id,
            event_name: run.event_name,
            status_message: run.status_message,
            state: HookRunState::pending(now),
        });
    }

    /// Completes a run and returns whether the run was already present in this cell.
    ///
    /// Quiet successes intentionally avoid persistent output. If they were never visible, they
    /// disappear immediately; if they had already drawn, they move into `QuietLinger`.
    pub(crate) fn complete_run(&mut self, run: HookRunSummary) -> bool {
        let Some(index) = self.runs.iter().position(|existing| existing.id == run.id) else {
            return false;
        };
        if hook_run_is_quiet_success(&run) {
            if !self.runs[index]
                .state
                .complete_quiet_success(Instant::now())
            {
                self.runs.remove(index);
            }
            return true;
        }
        let HookRunSummary {
            event_name,
            status_message,
            status,
            entries,
            ..
        } = run;
        let existing = &mut self.runs[index];
        existing.event_name = event_name;
        existing.status_message = status_message;
        existing.state = HookRunState::completed(status, entries);
        true
    }

    /// Adds a completed hook that did not pass through this live cell.
    ///
    /// This is used for replay/restoration paths where the final run summary is already known.
    pub(crate) fn add_completed_run(&mut self, run: HookRunSummary) {
        if hook_run_is_quiet_success(&run) {
            return;
        }
        let HookRunSummary {
            id,
            event_name,
            status_message,
            status,
            entries,
            ..
        } = run;
        self.runs.push(HookRunCell {
            id,
            event_name,
            status_message,
            state: HookRunState::completed(status, entries),
        });
    }

    pub(crate) fn next_timer_deadline(&self) -> Option<Instant> {
        self.runs
            .iter()
            .filter_map(|run| run.state.next_timer_deadline())
            .min()
    }

    #[cfg(test)]
    pub(crate) fn expire_quiet_runs_now_for_test(&mut self) {
        for run in &mut self.runs {
            run.expire_quiet_linger_now_for_test();
        }
    }

    #[cfg(test)]
    pub(crate) fn reveal_running_runs_now_for_test(&mut self) {
        let now = Instant::now();
        for run in &mut self.runs {
            run.reveal_running_now_for_test(now);
        }
    }

    #[cfg(test)]
    pub(crate) fn reveal_running_runs_after_delayed_redraw_for_test(&mut self) {
        let now = Instant::now();
        for run in &mut self.runs {
            run.reveal_running_after_delayed_redraw_for_test(now);
        }
    }
}

impl HistoryCell for HookCell {
    /// Builds viewport lines while coalescing adjacent visible-running hooks.
    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
        let mut lines = Vec::new();
        let mut running_group: Option<RunningHookGroup> = None;
        for run in &self.runs {
            if !run.state.should_render() {
                continue;
            }

            let Some(key) = run.running_group_key() else {
                // Completed runs keep their own output lines, so any pending running group must be
                // emitted before drawing the completed run.
                if let Some(group) = running_group.take() {
                    push_running_hook_group(&mut lines, &group, self.animations_enabled);
                }
                push_hook_line_separator(&mut lines);
                run.push_display_lines(&mut lines, self.animations_enabled);
                continue;
            };

            if let Some(group) = running_group.as_mut()
                && group.key == key
            {
                group.count += 1;
                // Preserve the earliest start time so grouped spinners do not reset when a later
                // adjacent hook is folded into the same line.
                group.start_time = earliest_instant(group.start_time, run.state.start_time());
                continue;
            }

            if let Some(group) =
                running_group.replace(RunningHookGroup::new(key, run.state.start_time()))
            {
                push_running_hook_group(&mut lines, &group, self.animations_enabled);
            }
        }
        if let Some(group) = running_group {
            push_running_hook_group(&mut lines, &group, self.animations_enabled);
        }
        lines
    }

    /// Hook transcript output matches viewport output.
    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.display_lines(width)
    }

    fn raw_lines(&self) -> Vec<Line<'static>> {
        plain_lines(self.display_lines(u16::MAX))
    }

    /// Produces a coarse cache key for transcript overlays while hook animations are active.
    fn transcript_animation_tick(&self) -> Option<u64> {
        if !self.animations_enabled {
            return None;
        }
        let elapsed = self
            .runs
            .iter()
            .filter(|run| run.state.is_running_visible())
            .find_map(|run| run.state.start_time())?
            .elapsed();
        Some(elapsed.as_millis() as u64 / 600)
    }
}

impl Renderable for HookCell {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        let lines = self.display_lines(area.width);
        let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
        paragraph.render(area, buf);
    }

    fn desired_height(&self, width: u16) -> u16 {
        HistoryCell::desired_height(self, width)
    }
}

impl HookRunCell {
    #[cfg(test)]
    fn expire_quiet_linger_now_for_test(&mut self) {
        if let HookRunState::QuietLinger {
            removal_deadline, ..
        } = &mut self.state
        {
            *removal_deadline = Instant::now();
        }
    }

    #[cfg(test)]
    fn reveal_running_now_for_test(&mut self, now: Instant) {
        if let HookRunState::PendingReveal {
            reveal_deadline, ..
        } = &mut self.state
        {
            *reveal_deadline = now;
        }
    }

    #[cfg(test)]
    fn reveal_running_after_delayed_redraw_for_test(&mut self, now: Instant) {
        if let HookRunState::PendingReveal {
            reveal_deadline, ..
        } = &mut self.state
        {
            let delayed_deadline = now
                .checked_sub(QUIET_HOOK_MIN_VISIBLE + Duration::from_millis(100))
                .unwrap_or(now);
            *reveal_deadline = delayed_deadline;
        }
    }

    /// Returns the grouping key only for states that render as running.
    fn running_group_key(&self) -> Option<RunningHookGroupKey> {
        self.state
            .is_running_visible()
            .then(|| RunningHookGroupKey {
                event_name: self.event_name,
                status_message: self.status_message.clone(),
            })
    }

    /// Appends the lines for a single, ungrouped hook run.
    fn push_display_lines(&self, lines: &mut Vec<Line<'static>>, animations_enabled: bool) {
        let label = hook_event_label(self.event_name);
        match &self.state {
            HookRunState::VisibleRunning { start_time, .. }
            | HookRunState::QuietLinger { start_time, .. } => {
                let hook_text = format!("Running {label} hook");
                push_running_hook_header(
                    lines,
                    &hook_text,
                    Some(*start_time),
                    self.status_message.as_deref(),
                    animations_enabled,
                );
            }
            HookRunState::Completed { status, entries } => {
                let status_text = format!("{status:?}").to_lowercase();
                let bullet = hook_completed_bullet(*status, entries);
                lines.push(
                    vec![
                        bullet,
                        " ".into(),
                        format!("{label} hook ({status_text})").into(),
                    ]
                    .into(),
                );
                for entry in entries {
                    let prefix = hook_output_prefix(entry.kind);
                    let mut output_lines = entry.text.split('\n');
                    if let Some(first_line) = output_lines.next() {
                        lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into());
                    }
                    for line in output_lines {
                        if line.is_empty() {
                            lines.push("".into());
                        } else {
                            lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into());
                        }
                    }
                }
            }
            HookRunState::PendingReveal { .. } => {}
        }
    }
}

impl HookRunState {
    /// Creates the hidden initial state for a live hook run.
    fn pending(start_time: Instant) -> Self {
        Self::PendingReveal {
            start_time,
            reveal_deadline: start_time + HOOK_RUN_REVEAL_DELAY,
        }
    }

    /// Creates the persistent final state for a hook with visible output or a notable status.
    fn completed(status: HookRunStatus, entries: Vec<HookOutputEntry>) -> Self {
        Self::Completed { status, entries }
    }

    /// Returns true while the run is still waiting for a completion event or timer cleanup.
    fn is_active(&self) -> bool {
        match self {
            HookRunState::PendingReveal { .. }
            | HookRunState::VisibleRunning { .. }
            | HookRunState::QuietLinger { .. } => true,
            HookRunState::Completed { .. } => false,
        }
    }

    /// Returns true when this run contributes at least one line to the current render.
    fn should_render(&self) -> bool {
        match self {
            HookRunState::VisibleRunning { .. }
            | HookRunState::QuietLinger { .. }
            | HookRunState::Completed { .. } => true,
            HookRunState::PendingReveal { .. } => false,
        }
    }

    /// Returns true for completed runs that should survive outside the active cell.
    fn has_persistent_output(&self) -> bool {
        match self {
            HookRunState::Completed { status, entries } => {
                *status != HookRunStatus::Completed || !entries.is_empty()
            }
            HookRunState::PendingReveal { .. }
            | HookRunState::VisibleRunning { .. }
            | HookRunState::QuietLinger { .. } => false,
        }
    }

    /// Returns the original start time for active states.
    ///
    /// Completed runs no longer animate, so they intentionally have no start time.
    fn start_time(&self) -> Option<Instant> {
        match self {
            HookRunState::PendingReveal { start_time, .. }
            | HookRunState::VisibleRunning { start_time, .. }
            | HookRunState::QuietLinger { start_time, .. } => Some(*start_time),
            HookRunState::Completed { .. } => None,
        }
    }

    /// Returns true when the run should be treated as an in-progress row.
    fn is_running_visible(&self) -> bool {
        matches!(
            self,
            HookRunState::VisibleRunning { .. } | HookRunState::QuietLinger { .. }
        )
    }

    /// Reveals a pending run once its deadline has passed.
    ///
    /// Returns true only when this call changes the state, allowing timer callbacks to avoid
    /// unnecessary redraws.
    fn reveal_if_due(&mut self, now: Instant) -> bool {
        let HookRunState::PendingReveal {
            start_time,
            reveal_deadline,
        } = self
        else {
            return false;
        };
        if now < *reveal_deadline {
            return false;
        }
        *self = HookRunState::VisibleRunning {
            start_time: *start_time,
            visible_since: now,
        };
        true
    }

    /// Returns the next state-machine deadline owned by this run.
    fn next_timer_deadline(&self) -> Option<Instant> {
        match self {
            HookRunState::PendingReveal {
                reveal_deadline, ..
            } => Some(*reveal_deadline),
            HookRunState::QuietLinger {
                removal_deadline, ..
            } => Some(*removal_deadline),
            HookRunState::VisibleRunning { .. } | HookRunState::Completed { .. } => None,
        }
    }

    /// Returns true once a quiet success has lingered for long enough.
    fn quiet_linger_expired(&self, now: Instant) -> bool {
        match self {
            HookRunState::QuietLinger {
                removal_deadline, ..
            } => now >= *removal_deadline,
            HookRunState::PendingReveal { .. }
            | HookRunState::VisibleRunning { .. }
            | HookRunState::Completed { .. } => false,
        }
    }

    /// Converts a visible quiet success into a temporary linger state.
    ///
    /// Returns false when the success should be removed immediately: either it was never visible or
    /// it has already stayed visible for the minimum duration.
    fn complete_quiet_success(&mut self, now: Instant) -> bool {
        let HookRunState::VisibleRunning {
            start_time,
            visible_since,
            ..
        } = self
        else {
            return false;
        };
        let start_time = *start_time;
        let minimum_deadline = *visible_since + QUIET_HOOK_MIN_VISIBLE;
        if now >= minimum_deadline {
            return false;
        }
        *self = HookRunState::QuietLinger {
            start_time,
            removal_deadline: minimum_deadline,
        };
        true
    }
}

impl RunningHookGroup {
    fn new(key: RunningHookGroupKey, start_time: Option<Instant>) -> Self {
        Self {
            key,
            start_time,
            count: 1,
        }
    }
}

/// Emits one grouped running-hook status row.
fn push_running_hook_group(
    lines: &mut Vec<Line<'static>>,
    group: &RunningHookGroup,
    animations_enabled: bool,
) {
    push_hook_line_separator(lines);
    let label = hook_event_label(group.key.event_name);
    let hook_text = if group.count == 1 {
        format!("Running {label} hook")
    } else {
        format!("Running {} {label} hooks", group.count)
    };
    push_running_hook_header(
        lines,
        &hook_text,
        group.start_time,
        group.key.status_message.as_deref(),
        animations_enabled,
    );
}

/// Emits the animated or static header used by all running hook rows.
fn push_running_hook_header(
    lines: &mut Vec<Line<'static>>,
    hook_text: &str,
    start_time: Option<Instant>,
    status_message: Option<&str>,
    animations_enabled: bool,
) {
    let mut header = Vec::new();
    let motion_mode = MotionMode::from_animations_enabled(animations_enabled);
    if let Some(indicator) =
        activity_indicator(start_time, motion_mode, ReducedMotionIndicator::Hidden)
    {
        header.push(indicator);
        header.push(" ".into());
    }
    header.extend(shimmer_text(hook_text, motion_mode));
    if !animations_enabled && let Some(span) = header.last_mut() {
        span.style = span.style.patch(Style::default().bold());
    }
    if let Some(status_message) = status_message
        && !status_message.is_empty()
    {
        header.push(": ".into());
        header.push(status_message.to_string().dim());
    }
    lines.push(header.into());
}

/// Adds a blank separator between hook blocks without leaving a leading blank line.
fn push_hook_line_separator(lines: &mut Vec<Line<'static>>) {
    if !lines.is_empty() {
        lines.push("".into());
    }
}

/// Combines optional instants while preserving the earliest known start time.
fn earliest_instant(left: Option<Instant>, right: Option<Instant>) -> Option<Instant> {
    match (left, right) {
        (Some(left), Some(right)) => Some(left.min(right)),
        (Some(left), None) => Some(left),
        (None, Some(right)) => Some(right),
        (None, None) => None,
    }
}

pub(crate) fn new_active_hook_cell(run: HookRunSummary, animations_enabled: bool) -> HookCell {
    HookCell::new_active(run, animations_enabled)
}

pub(crate) fn new_completed_hook_cell(run: HookRunSummary, animations_enabled: bool) -> HookCell {
    HookCell::new_completed(run, animations_enabled)
}

/// Returns true for hook completions that should be invisible in history.
fn hook_run_is_quiet_success(run: &HookRunSummary) -> bool {
    run.status == HookRunStatus::Completed && run.entries.is_empty()
}

fn hook_completed_bullet(status: HookRunStatus, entries: &[HookOutputEntry]) -> Span<'static> {
    match status {
        HookRunStatus::Completed => {
            if entries
                .iter()
                .any(|entry| entry.kind == HookOutputEntryKind::Warning)
            {
                "•".bold()
            } else {
                "•".green().bold()
            }
        }
        HookRunStatus::Blocked | HookRunStatus::Failed | HookRunStatus::Stopped => "•".red().bold(),
        HookRunStatus::Running => "•".into(),
    }
}

fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str {
    match kind {
        HookOutputEntryKind::Warning => "warning: ",
        HookOutputEntryKind::Stop => "stop: ",
        HookOutputEntryKind::Feedback => "feedback: ",
        HookOutputEntryKind::Context => "hook context: ",
        HookOutputEntryKind::Error => "error: ",
    }
}

fn hook_event_label(event_name: HookEventName) -> &'static str {
    match event_name {
        HookEventName::PreToolUse => "PreToolUse",
        HookEventName::PermissionRequest => "PermissionRequest",
        HookEventName::PostToolUse => "PostToolUse",
        HookEventName::PreCompact => "PreCompact",
        HookEventName::PostCompact => "PostCompact",
        HookEventName::SessionStart => "SessionStart",
        HookEventName::UserPromptSubmit => "UserPromptSubmit",
        HookEventName::SubagentStart => "SubagentStart",
        HookEventName::SubagentStop => "SubagentStop",
        HookEventName::Stop => "Stop",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::PathBufExt;
    use crate::test_support::test_path_buf;
    use pretty_assertions::assert_eq;
    use ratatui::style::Modifier;

    #[test]
    fn completed_hook_with_warning_uses_default_bold_bullet() {
        let entries = vec![HookOutputEntry {
            kind: HookOutputEntryKind::Warning,
            text: "Heads up from the hook".to_string(),
        }];

        let bullet = hook_completed_bullet(HookRunStatus::Completed, &entries);

        assert_eq!(bullet.content.as_ref(), "•");
        assert_eq!(bullet.style.fg, None);
        assert!(bullet.style.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn completed_hook_multiline_context_preserves_display_and_raw_lines() {
        let cell = completed_hook_cell(
            HookEventName::SessionStart,
            HookRunStatus::Completed,
            vec![HookOutputEntry {
                kind: HookOutputEntryKind::Context,
                text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory"
                    .to_string(),
            }],
        );
        let expected = vec![
            "• SessionStart hook (completed)".to_string(),
            "  hook context: ## Working Memory Recall".to_string(),
            "".to_string(),
            "    Source: Codex compaction".to_string(),
            "    Scope: Durable workspace memory".to_string(),
        ];

        assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected);
        assert_eq!(line_texts(&cell.raw_lines()), expected);
    }

    #[test]
    fn completed_hook_multiline_warning_prefixes_first_line_only() {
        let cell = completed_hook_cell(
            HookEventName::PostToolUse,
            HookRunStatus::Completed,
            vec![HookOutputEntry {
                kind: HookOutputEntryKind::Warning,
                text: "Heads up\nReview generated files".to_string(),
            }],
        );

        assert_eq!(
            line_texts(&cell.display_lines(/*width*/ 80)),
            vec![
                "• PostToolUse hook (completed)".to_string(),
                "  warning: Heads up".to_string(),
                "    Review generated files".to_string(),
            ]
        );
    }

    #[test]
    fn pending_hook_does_not_animate_transcript() {
        let cell =
            HookCell::new_active(hook_run_summary("hook-1"), /*animations_enabled*/ true);

        assert_eq!(cell.transcript_animation_tick(), None);
    }

    #[test]
    fn visible_hook_animates_transcript_when_animations_enabled() {
        let mut cell =
            HookCell::new_active(hook_run_summary("hook-1"), /*animations_enabled*/ true);
        cell.reveal_running_runs_now_for_test();
        cell.advance_time(Instant::now());

        assert_eq!(cell.transcript_animation_tick(), Some(0));
    }

    #[test]
    fn visible_hook_does_not_animate_transcript_when_animations_disabled() {
        let mut cell = HookCell::new_active(
            hook_run_summary("hook-1"),
            /*animations_enabled*/ false,
        );
        cell.reveal_running_runs_now_for_test();
        cell.advance_time(Instant::now());

        assert_eq!(cell.transcript_animation_tick(), None);
    }

    #[test]
    fn visible_hook_without_animations_omits_spinner() {
        let mut cell = HookCell::new_active(
            hook_run_summary("hook-1"),
            /*animations_enabled*/ false,
        );
        cell.reveal_running_runs_now_for_test();
        cell.advance_time(Instant::now());

        let rendered: Vec<String> = cell
            .display_lines(/*width*/ 80)
            .iter()
            .map(line_text)
            .collect();

        assert_eq!(
            rendered,
            vec!["Running PostToolUse hook: checking output policy".to_string()]
        );
    }

    fn completed_hook_cell(
        event_name: HookEventName,
        status: HookRunStatus,
        entries: Vec<HookOutputEntry>,
    ) -> HookCell {
        let mut run = hook_run_summary("hook-1");
        run.event_name = event_name;
        run.status = status;
        run.status_message = None;
        run.completed_at = Some(2);
        run.duration_ms = Some(1);
        run.entries = entries;
        HookCell::new_completed(run, /*animations_enabled*/ false)
    }

    fn line_texts(lines: &[Line<'_>]) -> Vec<String> {
        lines.iter().map(line_text).collect()
    }

    fn line_text(line: &Line<'_>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>()
    }

    fn hook_run_summary(id: &str) -> HookRunSummary {
        HookRunSummary {
            id: id.to_string(),
            event_name: HookEventName::PostToolUse,
            handler_type: codex_app_server_protocol::HookHandlerType::Command,
            execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
            scope: codex_app_server_protocol::HookScope::Turn,
            source_path: test_path_buf("/tmp/hooks.json").abs(),
            source: codex_app_server_protocol::HookSource::User,
            display_order: 0,
            status: HookRunStatus::Running,
            status_message: Some("checking output policy".to_string()),
            started_at: 1,
            completed_at: None,
            duration_ms: None,
            entries: Vec::new(),
        }
    }
}
