use crate::bash_funcs;
use crate::bash_symbols;
use crate::content_builder::{Tag, TaggedLine, TaggedSpan};
use crate::kill_on_drop_child::KillOnDropChild;
use crate::settings::{Placeholder, PromptAnimation, PromptWidget, PromptWidgetCustom};
#[cfg(not(test))]
use ansi_to_tui::IntoText;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{Arc, Mutex};

/// An animation whose frames have been processed through
/// [`expand_prompt_through_bash`].  Embedded directly inside
/// [`PromptSegment::Animation`] so that each animation carries its own render
/// logic without requiring a separate lookup table on [`PromptManager`].
#[derive(Debug, Clone)]
struct ProcessedAnimation {
    /// The animation name as it appears literally in the raw PS1 string
    /// (e.g. `COOL_SPINNER`).  Retained for debugging.
    name: String,
    /// Playback speed in frames per second.
    fps: f64,
    /// Pre-processed frames.  Each frame has been run through
    /// [`expand_prompt_through_bash`] so bash prompt escapes (e.g. `\u`, `\w`)
    /// and ANSI colour codes are already resolved into [`Span`]s.
    frames: Vec<Vec<Span<'static>>>,
    /// When true the animation reverses direction at each end instead of
    /// wrapping around (ping-pong / bounce mode).
    ping_pong: bool,
}

impl ProcessedAnimation {
    pub fn patch_style(mut self, style: Style) -> Self {
        for frame in &mut self.frames {
            for span in frame {
                span.style = style.patch(span.style);
            }
        }
        self
    }
}

/// State of a running custom widget command.
///
/// Held directly inside `PromptSegment::WidgetCustom`.  Each segment is fully
/// independent: the same widget name appearing in PS1 and RPS1 produces two
/// independent segments that each run the command separately.
enum WidgetCustomState {
    /// Command is still running (or has not yet been polled).
    Pending {
        placeholder: Vec<TaggedSpan<'static>>,
        child: KillOnDropChild,
        /// The command that was spawned, retained for log messages.
        command: Vec<String>,
        /// Shared storage to write the output into when the command finishes,
        /// so that `Placeholder::Prev` can use it on the next render cycle.
        prev_output_cell: Arc<Mutex<Vec<TaggedSpan<'static>>>>,
    },
    /// Command finished successfully; pre-tagged output spans are stored here
    /// so that the tag is applied only once rather than on every render.
    Done(Vec<TaggedSpan<'static>>),
    /// Command exited with a non-zero exit code or could not be spawned.
    Failed(WidgetFailure),
}

impl std::fmt::Debug for WidgetCustomState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            WidgetCustomState::Pending { .. } => f.write_str("WidgetCustomState::Pending"),
            WidgetCustomState::Done(_) => f.write_str("WidgetCustomState::Done"),
            WidgetCustomState::Failed(_) => f.write_str("WidgetCustomState::Failed"),
        }
    }
}

/// Failure details returned by a custom widget command.
struct WidgetFailure {
    exit_code: Option<i32>,
    stdout: String,
    stderr: String,
}

impl WidgetFailure {
    /// Build a set of error [`TaggedSpan`]s that visualise this failure in the
    /// prompt.  The label is `"command failed (exit N)"` when an exit code is
    /// available, or `"command failed"` for spawn errors.  The full stdout and
    /// stderr are emitted at `debug` level.
    fn to_error_spans(&self) -> Vec<TaggedSpan<'static>> {
        let style = Style::default()
            .fg(Color::Red)
            .add_modifier(Modifier::BOLD | Modifier::SLOW_BLINK);
        let label = match self.exit_code {
            Some(code) => format!("command failed (exit {})", code),
            None => "command failed".to_string(),
        };
        log::debug!(
            "Custom widget failed — stdout: {:?}  stderr: {:?}",
            self.stdout,
            self.stderr
        );
        vec![TaggedSpan::new(Span::styled(label, style), Tag::Ps1Prompt)]
    }
}

/// A segment of a rendered prompt line.
///
/// Prompt strings are parsed into sequences of `PromptSegment`s at
/// construction time.  At render time each segment is cheaply converted to a
/// ratatui [`Span`]: static segments are used as-is, dynamic-time segments are
/// formatted with the current wall-clock time, and animation segments render
/// the appropriate frame for the current time.
#[derive(Debug)]
enum PromptSegment {
    /// A fully-resolved span (text + style) with no further substitution needed.
    Static(Span<'static>),
    /// A bash time escape sequence (`\t`, `\T`, `\@`, `\A`, `\D{…}`).
    /// Rendered by formatting the current time with the stored chrono
    /// format string and applying the span's style.
    DynamicTime {
        strftime: String,
        style: ratatui::style::Style,
    },
    /// A custom animation.  Rendered by selecting the appropriate frame for the
    /// current time and emitting its pre-resolved [`Span`]s directly.
    ///
    /// Boxed to keep the `PromptSegment` enum size small.
    Animation(Box<ProcessedAnimation>),
    /// The detected current-working-directory substring in the prompt.
    /// Holds one span per path segment, where each segment owns the slash
    /// to its left.  E.g. `~/foo/bar` is stored as `["~", "/foo", "/bar"]`.
    /// At render time the spans are emitted in order with per-segment tags.
    Cwd(Vec<Span<'static>>),
    /// A mouse-mode widget.  Rendered as `enabled_text` when mouse capture is
    /// active, otherwise `disabled_text`.
    ///
    /// Both texts are stored as pre-tagged [`TaggedSpan`]s so that the tag is
    /// applied once at construction rather than on every render.
    WidgetMouseMode {
        enabled_text: Vec<TaggedSpan<'static>>,
        disabled_text: Vec<TaggedSpan<'static>>,
    },
    /// A leader-mode widget. Rendered as `active_text` when the leader key is active.
    WidgetLeaderMode {
        active_text: Vec<TaggedSpan<'static>>,
        inactive_text: Vec<TaggedSpan<'static>>,
    },
    /// A clickable widget that copies the current command buffer to the clipboard.
    WidgetCopyBuffer { text: Vec<TaggedSpan<'static>> },
    /// A widget that displays how long ago the flyline app last closed.
    ///
    /// The formatted text is computed once at construction time (when the
    /// `PromptStringBuilder` processes the widget name) and stored as a
    /// static string.  At render time the stored text is emitted directly
    /// without any further computation.
    ///
    /// The widget's text is styled with `base_style` (the surrounding prompt
    /// span's style).
    WidgetLastCommandDuration { text: String, base_style: Style },
    /// A custom-command widget.  On each render the child process is polled
    /// with `try_wait`; once it exits the output (processed through
    /// `expand_prompt_through_bash`) is shown.  While still pending the
    /// placeholder string is shown.
    ///
    /// Each segment is fully independent: two occurrences of the same widget
    /// name in a prompt string each run their own process.
    ///
    /// `base_style` is the style of the prompt span in which the widget name
    /// appeared.  At render time each output span's own style overrides this
    /// base (i.e. `base_style.patch(span.style)`).
    WidgetCustom {
        state: WidgetCustomState,
        base_style: Style,
    },
}

pub struct PromptManager {
    prompt: Vec<Vec<PromptSegment>>,
    prompt_final: Option<Vec<Vec<PromptSegment>>>,
    rprompt: Vec<Vec<PromptSegment>>,
    rprompt_final: Option<Vec<Vec<PromptSegment>>>,
    fill_span: Vec<PromptSegment>,
    fill_span_final: Option<Vec<PromptSegment>>,
    /// Time captured at construction; used when animations are disabled so
    /// that time-based prompt fields show the session-start time rather than
    /// updating on every render.
    construction_time: chrono::DateTime<chrono::Local>,
    /// The current working directory at the time the prompt was constructed.
    /// Used by `PromptCwdEdit` mode to compute the path for a given CWD segment index.
    cwd: String,
}

/// Pass a raw bash prompt string (with any time-code placeholders already
/// substituted) through bash's `decode_prompt_string`, then convert the
/// decoded output to a `Vec<Line<'static>>` via [`IntoText`].
///
/// `\[` / `\]` non-printing-sequence markers are stripped before the string is
/// handed to `decode_prompt_string` because they are Bash-specific and not
/// meaningful to ANSI parsers.  Trailing newlines and carriage returns are
/// stripped from each span.
///
/// Returns `None` when the string cannot be processed (e.g. contains interior
/// NUL bytes or bash returns a null pointer).
#[cfg(not(test))]
fn expand_prompt_through_bash(raw: String) -> Option<Vec<Line<'static>>> {
    if raw.is_empty() {
        return Some(vec![]);
    }

    // Strip literal `\[` / `\]` non-printing-sequence markers before handing
    // the string to `decode_prompt_string`.
    let raw = raw.replace("\\[", "").replace("\\]", "");

    let c_prompt = std::ffi::CString::new(raw).ok()?;

    let _guard = crate::bash_symbols::BASH_LOCK.lock();

    let decoded = unsafe {
        #[cfg(not(feature = "pre_bash_4_4"))]
        let decoded_prompt_cstr = bash_symbols::decode_prompt_string(c_prompt.as_ptr(), 1);
        #[cfg(feature = "pre_bash_4_4")]
        let decoded_prompt_cstr = bash_symbols::decode_prompt_string(c_prompt.as_ptr());
        if decoded_prompt_cstr.is_null() {
            log::warn!("decode_prompt_string returned null");
            return None;
        }

        let decoded = std::ffi::CStr::from_ptr(decoded_prompt_cstr)
            .to_str()
            .ok()?
            .to_string();

        // `decode_prompt_string` returns an allocated buffer.
        bash_symbols::locked_xfree(decoded_prompt_cstr as *mut std::ffi::c_void);

        decoded
    };

    let mut lines = decoded.into_text().ok()?.lines;
    for line in &mut lines {
        for span in &mut line.spans {
            let raw = span.content.as_ref();
            let stripped = raw.trim_end_matches(&['\n', '\r'][..]);
            if stripped.len() != raw.len() {
                log::debug!("Stripping trailing newline/carriage return from prompt line span");
                span.content = stripped.to_owned().into();
            }
        }
    }

    Some(lines)
}

/// In test builds the bash FFI symbols are not linked; this function returns
/// the raw string unchanged (wrapped in a single [`Line`]) so that unit tests
/// can exercise the prompt-rendering logic without requiring a live bash
/// process.
#[cfg(test)]
fn expand_prompt_through_bash(raw: String) -> Option<Vec<Line<'static>>> {
    if raw.is_empty() {
        return Some(vec![]);
    }
    Some(vec![Line::raw(raw)])
}

/// Builds expanded prompt segment lines from raw bash prompt strings while
/// accumulating a shared map of time-placeholder identifiers to chrono format
/// strings and holding pre-processed animation data.
///
/// A single `PromptStringBuilder` should be used for all prompt variables
/// (PS1, RPS1 / RPROMPT, PS1_FILL) so that placeholder identifiers are unique
/// across all of them.
struct PromptStringBuilder<'a> {
    /// Monotonically increasing counter used to generate unique placeholder IDs.
    counter: u32,
    /// Accumulated map of placeholder → chrono format string.
    /// Used during `expand_prompt_string` to recognise which spans contain
    /// time placeholders and convert them into [`PromptSegment::DynamicTime`].
    time_map: HashMap<String, String>,
    /// Pre-processed animations.  Used by [`expand_span_to_segments`] to
    /// recognise animation-name placeholders and produce
    /// [`PromptSegment::Animation`] segments in the same pass.
    animations: Vec<ProcessedAnimation>,
    /// Raw prompt widget definitions.  Used by [`expand_span_to_segments`] to
    /// recognise widget-name placeholders and produce the appropriate
    /// `PromptSegment::Widget*` segments.  Each occurrence of a widget name in
    /// the prompt text produces a fully independent segment (and, for custom
    /// widgets, a separate process invocation).
    widgets: &'a [PromptWidget],
    /// Current working directory, used to detect CWD substrings in prompt spans.
    cwd: Option<String>,
    /// Home directory, used to recognise `~`-prefixed path representations.
    home: Option<String>,
    /// Timestamp of the most recent flyline app session close.
    /// Passed through to [`PromptSegment::WidgetLastCommandDuration`] so that
    /// the elapsed duration can be computed at render time.
    last_app_closed_at: Option<std::time::Instant>,
}

impl<'a> PromptStringBuilder<'a> {
    fn new(animations: Vec<ProcessedAnimation>, widgets: &'a [PromptWidget]) -> Self {
        Self {
            counter: 0,
            time_map: HashMap::new(),
            animations,
            widgets,
            cwd: None,
            home: None,
            last_app_closed_at: None,
        }
    }

    /// Set the current working directory and home directory for CWD detection.
    fn with_cwd(mut self, cwd: String, home: Option<String>) -> Self {
        self.cwd = Some(cwd);
        self.home = home;
        self
    }

    /// Set the timestamp of the most recent flyline app session close.
    fn with_last_app_closed_at(mut self, t: Option<std::time::Instant>) -> Self {
        self.last_app_closed_at = t;
        self
    }

    /// Scan a raw bash prompt string and replace every time format escape
    /// sequence with a unique 8-character placeholder, recording the mapping
    /// in `self.time_map`.  Returns the modified string.
    ///
    /// Recognised bash time escape sequences (see
    /// <https://www.gnu.org/software/bash/manual/html_node/Controlling-the-Prompt.html>):
    ///
    /// | Sequence     | Meaning                        | Chrono format |
    /// |--------------|--------------------------------|---------------|
    /// | `\t`         | 24-hour HH:MM:SS               | `%H:%M:%S`    |
    /// | `\T`         | 12-hour HH:MM:SS               | `%I:%M:%S`    |
    /// | `\@`         | 12-hour am/pm                  | `%I:%M %p`    |
    /// | `\A`         | 24-hour HH:MM                  | `%H:%M`       |
    /// | `\D{format}` | chrono format string (custom)  | `format`      |
    fn extract_time_codes(&mut self, s: &str) -> String {
        let mut result = String::with_capacity(s.len());
        let mut chars = s.chars().peekable();

        while let Some(c) = chars.next() {
            if c != '\\' {
                result.push(c);
                continue;
            }

            match chars.peek().copied() {
                Some('\\') => {
                    // Escaped backslash — pass both through so `decode_prompt_string`
                    // still sees `\\` as a literal `\`.
                    result.push('\\');
                    result.push('\\');
                    chars.next();
                }
                Some('t') => {
                    chars.next();
                    let id = self.next_id();
                    self.time_map.insert(id.clone(), "%H:%M:%S".to_string());
                    result.push_str(&id);
                }
                Some('T') => {
                    chars.next();
                    let id = self.next_id();
                    self.time_map.insert(id.clone(), "%I:%M:%S".to_string());
                    result.push_str(&id);
                }
                Some('@') => {
                    chars.next();
                    let id = self.next_id();
                    self.time_map.insert(id.clone(), "%I:%M %p".to_string());
                    result.push_str(&id);
                }
                Some('A') => {
                    chars.next();
                    let id = self.next_id();
                    self.time_map.insert(id.clone(), "%H:%M".to_string());
                    result.push_str(&id);
                }
                Some('D') => {
                    chars.next(); // consume 'D'
                    if chars.peek().copied() == Some('{') {
                        chars.next(); // consume '{'
                        let mut fmt = String::new();
                        for nc in chars.by_ref() {
                            if nc == '}' {
                                break;
                            }
                            fmt.push(nc);
                        }
                        // An empty \D{} falls back to 24-hour HH:MM:SS (%T).
                        // Bash would use strftime with the locale's time format here,
                        // but chrono does not expose a locale-aware equivalent, so %T
                        // is used as a reasonable default.
                        let chrono_fmt = if fmt.is_empty() {
                            "%T".to_string()
                        } else {
                            fmt
                        };
                        let id = self.next_id();
                        self.time_map.insert(id.clone(), chrono_fmt);
                        result.push_str(&id);
                    } else {
                        // Not \D{...} — pass through unchanged.
                        result.push('\\');
                        result.push('D');
                    }
                }
                _ => {
                    // Not a time code — pass the backslash through so
                    // `decode_prompt_string` can handle the sequence.
                    result.push('\\');
                }
            }
        }

        result
    }

    /// Expand a raw prompt string (e.g. from `PS1`, `RPS1`, or `PS1_FILL`)
    /// into a sequence of lines, each line being a sequence of
    /// [`PromptSegment`]s.
    ///
    /// The pipeline is:
    /// 1. [`extract_time_codes`] — replace bash time escape sequences with
    ///    unique placeholders, recording the mapping in `self.time_map`.
    /// 2. [`expand_prompt_through_bash`] — run the modified string through
    ///    bash's `decode_prompt_string` and parse ANSI colour codes into
    ///    `Line<'static>` values.
    /// 3. [`expand_span_to_segments`] — split each decoded span at
    ///    time-placeholder boundaries, producing `Static` or `DynamicTime`
    ///    segments.
    ///
    /// Returns `None` when the string cannot be processed.
    fn expand_prompt_string(&mut self, raw: String) -> Option<Vec<Vec<PromptSegment>>> {
        let modified = self.extract_time_codes(&raw);
        let lines = expand_prompt_through_bash(modified)?;
        let result = lines
            .into_iter()
            .map(|line| {
                let mut segs: Vec<PromptSegment> = line
                    .spans
                    .into_iter()
                    .flat_map(|span| self.expand_span_to_segments(span))
                    .collect();
                // If multiple Cwd segments are present (because the same path
                // substring appeared in more than one prompt span), keep only
                // the longest one and convert the rest back to Static segments.
                dedup_cwd_segments(&mut segs);
                segs
            })
            .collect();
        Some(result)
    }

    /// Split a single decoded [`Span`] into a sequence of [`PromptSegment`]s.
    ///
    /// Performs four successive passes, each replacing matching substrings
    /// inside any remaining [`PromptSegment::Static`] segments with the
    /// appropriate richer segment kind:
    /// 1. Time-placeholder strings → [`PromptSegment::DynamicTime`].
    /// 2. Animation names → [`PromptSegment::Animation`].
    /// 3. Widget names → `PromptSegment::Widget*`.
    /// 4. CWD substrings → [`PromptSegment::Cwd`].
    fn expand_span_to_segments(&self, span: Span<'static>) -> Vec<PromptSegment> {
        // Pass 1: time placeholders.
        let style = span.style;
        let segs = split_span_by(span, |text| {
            self.time_map
                .iter()
                .filter_map(|(id, fmt)| text.find(id.as_str()).map(|pos| (pos, id.len(), fmt)))
                .min_by_key(|(pos, _, _)| *pos)
                .map(|(pos, len, fmt)| {
                    (
                        pos,
                        len,
                        PromptSegment::DynamicTime {
                            strftime: fmt.clone(),
                            style,
                        },
                    )
                })
        });

        // Pass 2: animations.
        let segs = split_static_segments(segs, |s| {
            let style = s.style;
            split_span_by(s, |text| {
                self.animations
                    .iter()
                    .filter_map(|anim| {
                        text.find(anim.name.as_str())
                            .map(|pos| (pos, anim.name.len(), anim))
                    })
                    .min_by_key(|(pos, _, _)| *pos)
                    .map(|(pos, len, anim)| {
                        (
                            pos,
                            len,
                            PromptSegment::Animation(Box::new(anim.clone().patch_style(style))),
                        )
                    })
            })
        });

        // Pass 3: widgets.  Custom widgets with an empty command are excluded
        // (they have no valid name to match) so their placeholder text stays
        // literal.  Each match spawns a fresh independent widget segment.
        let last_app_closed_at = self.last_app_closed_at;
        let segs = split_static_segments(segs, |s| {
            let style = s.style;
            split_span_by(s, |text| {
                self.widgets
                    .iter()
                    .filter_map(|widget| {
                        // Custom widgets with an empty command line are inert and should not
                        // be matched in the prompt text.
                        if let PromptWidget::Custom(w) = widget
                            && w.command.is_empty()
                        {
                            return None;
                        }
                        let name = widget.name();
                        text.find(name).map(|pos| (pos, name.len(), widget))
                    })
                    .min_by_key(|(pos, _, _)| *pos)
                    .map(|(pos, len, widget)| {
                        (
                            pos,
                            len,
                            make_widget_segment(widget, style, last_app_closed_at),
                        )
                    })
            })
        });

        // Pass 4: CWD detection.
        match self.cwd.as_deref() {
            Some(cwd) => {
                let home = self.home.as_deref();
                split_static_segments(segs, |s| split_static_span_by_cwd(s, cwd, home))
            }
            None => segs,
        }
    }

    /// Allocate the next placeholder identifier and advance the counter.
    fn next_id(&mut self) -> String {
        let id = format!("FLYT{:04X}", self.counter);
        self.counter += 1;
        id
    }
}

/// Apply `split` to every [`PromptSegment::Static`] in `segs`, leaving every
/// other segment untouched.  Used by the time/animation/widget/cwd passes in
/// [`PromptStringBuilder::expand_span_to_segments`] to chain successive
/// substring-splitting passes over the still-unmatched portions of text.
fn split_static_segments<F>(segs: Vec<PromptSegment>, mut split: F) -> Vec<PromptSegment>
where
    F: FnMut(Span<'static>) -> Vec<PromptSegment>,
{
    segs.into_iter()
        .flat_map(|seg| match seg {
            PromptSegment::Static(s) => split(s),
            other => vec![other],
        })
        .collect()
}

/// Generic substring-splitting helper used by the time, animation and widget
/// passes.  Repeatedly invokes `find_next` on the unmatched tail of `span` to
/// locate the next match (returning byte position, byte length and the
/// replacement segment), and emits a [`PromptSegment::Static`] for the text
/// between matches.  Always returns at least one segment.
fn split_span_by(
    span: Span<'static>,
    mut find_next: impl FnMut(&str) -> Option<(usize, usize, PromptSegment)>,
) -> Vec<PromptSegment> {
    let style = span.style;
    let mut result: Vec<PromptSegment> = Vec::new();
    let mut remaining: String = span.content.into_owned();

    while let Some((pos, len, segment)) = find_next(&remaining) {
        if pos > 0 {
            result.push(PromptSegment::Static(Span::styled(
                remaining[..pos].to_owned(),
                style,
            )));
        }
        result.push(segment);
        remaining = remaining[pos + len..].to_owned();
    }

    if !remaining.is_empty() {
        result.push(PromptSegment::Static(Span::styled(remaining, style)));
    }

    // Ensure at least one segment is always returned (e.g. for an empty span
    // with no matches).
    if result.is_empty() {
        result.push(PromptSegment::Static(Span::styled(String::new(), style)));
    }

    result
}

/// Build a [`PromptSegment`] for the given [`PromptWidget`].
///
/// For mouse-mode widgets the enabled/disabled texts are expanded through
/// bash and stored as pre-tagged [`TaggedSpan`]s.
///
/// For custom widgets the command is spawned directly as a child process.
/// A `block` timeout of 0 (the default when `block` is not specified) means
/// we do a single non-blocking `try_wait` and immediately put the child in
/// `Pending` if it hasn't finished yet.  Any positive value (including
/// `i32::MAX` ≈ 24.8 days, which is effectively indefinite) polls up to that
/// many milliseconds before moving on.
fn make_widget_segment(
    widget: &PromptWidget,
    base_style: Style,
    last_app_closed_at: Option<std::time::Instant>,
) -> PromptSegment {
    match widget {
        PromptWidget::MouseMode {
            enabled_text,
            disabled_text,
            ..
        } => PromptSegment::WidgetMouseMode {
            enabled_text: stdout_to_tagged_spans(enabled_text.clone()),
            disabled_text: stdout_to_tagged_spans(disabled_text.clone()),
        },
        PromptWidget::LeaderMode {
            active_text,
            inactive_text,
            ..
        } => PromptSegment::WidgetLeaderMode {
            active_text: stdout_to_tagged_spans(active_text.clone()),
            inactive_text: stdout_to_tagged_spans(inactive_text.clone()),
        },
        PromptWidget::CopyBuffer { text, .. } => {
            // Apply the surrounding prompt span's base style to each span of
            // the widget output so the widget visually inherits the styling
            // of the span it appears in (matching Animation/Cwd behaviour).
            // The widget output's own style takes precedence
            // (`base_style.patch(span.style)`).
            let text = stdout_to_tagged_spans_with_tag(text.clone(), Tag::PromptCopyBufferWidget)
                .into_iter()
                .map(|mut ts| {
                    ts.span.style = base_style.patch(ts.span.style);
                    ts
                })
                .collect();
            PromptSegment::WidgetCopyBuffer { text }
        }
        PromptWidget::Custom(w) => {
            let state = match spawn_widget_child(&w.command) {
                Err(failure) => WidgetCustomState::Failed(failure),
                Ok(mut child) => {
                    // Default to 0 ms when `block` is not specified; a 0 ms
                    // timeout performs a single non-blocking try_wait and moves
                    // on immediately.  i32::MAX polls for ~24.8 days, which is
                    // effectively indefinite.
                    let timeout_ms = w.block.unwrap_or(0);
                    let timeout = std::time::Duration::from_millis(timeout_ms as u64);
                    let start = std::time::Instant::now();
                    let done_status = loop {
                        match child.try_wait() {
                            Ok(Some(status)) => break Some(Ok(status)),
                            Ok(None) => {
                                if start.elapsed() >= timeout {
                                    break None;
                                }
                                std::thread::sleep(std::time::Duration::from_millis(5));
                            }
                            Err(e) => break Some(Err(e)),
                        }
                    };
                    match done_status {
                        Some(Ok(status)) => {
                            collect_and_finalize(&mut child, status, &w.command, &w.prev_output)
                        }
                        Some(Err(e)) => {
                            log::error!("Custom prompt widget: try_wait error: {}", e);
                            WidgetCustomState::Failed(WidgetFailure {
                                exit_code: None,
                                stdout: String::new(),
                                stderr: e.to_string(),
                            })
                        }
                        None => {
                            // Timed out: keep the child running in the background.
                            let placeholder = resolve_placeholder(w);
                            WidgetCustomState::Pending {
                                placeholder,
                                child: KillOnDropChild::new(child),
                                command: w.command.clone(),
                                prev_output_cell: w.prev_output.clone(),
                            }
                        }
                    }
                }
            };
            PromptSegment::WidgetCustom { state, base_style }
        }
        PromptWidget::LastCommandDuration { .. } => {
            // Compute elapsed duration once at construction time; the result
            // is stored as a static string in the segment and reused on every
            // render without further computation.
            let elapsed = last_app_closed_at.map(|t| t.elapsed()).unwrap_or_default();
            let text = crate::content_utils::format_duration(elapsed);
            PromptSegment::WidgetLastCommandDuration { text, base_style }
        }
    }
}

/// If a prompt line has more than one [`PromptSegment::Cwd`] segment
/// (because the same path substring appeared in several prompt spans),
/// keep only the longest one and convert the shorter duplicates back to
/// plain [`PromptSegment::Static`] segments.
fn dedup_cwd_segments(segs: &mut Vec<PromptSegment>) {
    // Collect indices and text lengths of every Cwd segment.
    let cwd_indices: Vec<(usize, usize)> = segs
        .iter()
        .enumerate()
        .filter_map(|(i, seg)| {
            if let PromptSegment::Cwd(spans) = seg {
                let len: usize = spans.iter().map(|s| s.content.len()).sum();
                Some((i, len))
            } else {
                None
            }
        })
        .collect();

    if cwd_indices.len() <= 1 {
        return;
    }

    // Index of the Cwd segment with the most text content.
    let longest_idx = cwd_indices
        .iter()
        .max_by_key(|(_, len)| *len)
        .map(|(i, _)| *i)
        .unwrap();

    // Convert all shorter Cwd segments back to Static.
    for (i, _) in &cwd_indices {
        if *i != longest_idx {
            if let PromptSegment::Cwd(spans) = &segs[*i] {
                let content: String = spans.iter().map(|s| s.content.as_ref()).collect();
                let style = spans.first().map(|s| s.style).unwrap_or_default();
                segs[*i] = PromptSegment::Static(Span::styled(content, style));
            }
        }
    }
}

/// Resolve the placeholder string for a custom widget that is not yet done.
fn resolve_placeholder(w: &PromptWidgetCustom) -> Vec<TaggedSpan<'static>> {
    match &w.placeholder {
        Placeholder::Spaces(n) => vec![TaggedSpan::new(Span::raw(" ".repeat(*n)), Tag::Ps1Prompt)],
        Placeholder::Prev => w.prev_output.lock().unwrap().clone(),
    }
}

/// Return the CWD representations we should look for in the prompt text.
///
/// The search prefers the fully expanded path and, when possible, the home
///-relative `~` form of that same path.
fn cwd_representations(cwd: &str, home: Option<&str>) -> Vec<String> {
    let cwd = normalize_cwd_for_matching(cwd);
    if cwd.is_empty() {
        return vec![];
    }

    let mut representations = vec![cwd.clone()];

    if let Some(home) = home {
        let home = normalize_cwd_for_matching(home);
        if !home.is_empty()
            && let Some(rest) = cwd.strip_prefix(&home)
            && (rest.is_empty() || rest.starts_with('/'))
        {
            let tilde = if rest.is_empty() {
                "~".to_string()
            } else {
                format!("~{}", rest)
            };
            if tilde != cwd {
                representations.push(tilde);
            }
        }
    }

    representations
}

fn normalize_cwd_for_matching(path: &str) -> String {
    if path == "/" {
        "/".to_string()
    } else {
        path.trim_end_matches('/').to_string()
    }
}

fn current_folder_name(cwd: &str) -> Option<&str> {
    let cwd = cwd.trim_end_matches('/');
    if cwd.is_empty() || cwd == "/" {
        Some("/")
    } else {
        cwd.rsplit('/').find(|segment| !segment.is_empty())
    }
}

fn extend_cwd_match_start_for_ellipsis(text: &str, start: usize) -> usize {
    let prefix = &text[..start];
    if prefix.ends_with('…') {
        return prefix.len() - '…'.len_utf8();
    }

    let dot_start = prefix
        .as_bytes()
        .iter()
        .rposition(|byte| *byte != b'.')
        .map(|idx| idx + 1)
        .unwrap_or(0);

    if dot_start < prefix.len() {
        dot_start
    } else {
        start
    }
}

fn extend_cwd_match_end_for_trailing_slash(text: &str, end: usize) -> usize {
    if text[end..].starts_with('/') {
        end + 1
    } else {
        end
    }
}

fn best_range_for_suffix(text: &str, suffix: &str) -> Option<Range<usize>> {
    let mut best = None;

    for (start, _) in text.match_indices(suffix) {
        let end = extend_cwd_match_end_for_trailing_slash(text, start + suffix.len());
        let start = extend_cwd_match_start_for_ellipsis(text, start);
        let candidate = start..end;
        if best
            .as_ref()
            .is_none_or(|current: &Range<usize>| candidate.len() > current.len())
        {
            best = Some(candidate);
        }
    }

    best
}

/// Find the longest substring of `text` that looks like the current working
/// directory.
///
/// We generate the possible CWD representations, then for each representation
/// look for the longest suffix that exists in `text`. Once we have that suffix,
/// we widen the match only when `text` includes an immediately-adjacent ellipsis
/// prefix or trailing slash.
fn find_cwd_in_span(text: &str, cwd: &str, home: Option<&str>) -> Option<Range<usize>> {
    let current_folder = current_folder_name(cwd)?;

    let mut best = None;
    for representation in cwd_representations(cwd, home) {
        let max_start = representation.len().saturating_sub(current_folder.len());
        let suffix_starts = representation
            .char_indices()
            .map(|(idx, _)| idx)
            .chain(std::iter::once(representation.len()))
            .take_while(|idx| *idx <= max_start);

        for start in suffix_starts {
            let suffix = &representation[start..];
            if let Some(candidate) = best_range_for_suffix(text, suffix) {
                if best
                    .as_ref()
                    .is_none_or(|current: &Range<usize>| candidate.len() > current.len())
                {
                    best = Some(candidate);
                }
                break;
            }
        }
    }

    best
}

/// Split a static [`Span`] at the first (longest) detected CWD substring,
/// producing up to three segments: an optional `Static` prefix, a `Cwd`
/// segment for the matched path text, and an optional `Static` suffix.
///
/// If no CWD-like substring is found the original span is returned unchanged
/// as a single `Static` segment.
fn split_static_span_by_cwd(
    span: Span<'static>,
    cwd: &str,
    home: Option<&str>,
) -> Vec<PromptSegment> {
    let text = span.content.as_ref().to_owned();
    let style = span.style;

    let Some(cwd_range) = find_cwd_in_span(&text, cwd, home) else {
        return vec![PromptSegment::Static(span)];
    };

    let mut result = Vec::new();
    if cwd_range.start > 0 {
        result.push(PromptSegment::Static(Span::styled(
            text[..cwd_range.start].to_owned(),
            style,
        )));
    }
    result.push(PromptSegment::Cwd(split_cwd_text_into_spans(
        &text[cwd_range.clone()],
        style,
    )));
    if cwd_range.end < text.len() {
        result.push(PromptSegment::Static(Span::styled(
            text[cwd_range.end..].to_owned(),
            style,
        )));
    }
    result
}

/// Split a CWD text string into individual [`Span`]s, with each `/` separator
/// as its own span distinct from the path segments on either side.
///
/// The leftmost span is the tilde or root `/`; each subsequent alternating pair
/// is a `"/"` separator followed by a directory name.
///
/// Examples:
/// - `"~/foo/bar"` → `["~", "/", "foo", "/", "bar"]`
/// - `"/home/foo/bar"` → `["/", "home", "/", "foo", "/", "bar"]`
/// - `"~"` → `["~"]`
/// - `"qwe/try/ooh"` → `["qwe", "/", "try", "/", "ooh"]`
fn split_cwd_text_into_spans(text: &str, style: ratatui::style::Style) -> Vec<Span<'static>> {
    if text.is_empty() {
        return vec![];
    }

    let mut result: Vec<Span<'static>> = Vec::new();

    if text.starts_with('~') {
        // The tilde segment never has a leading slash.
        // Find the first slash after '~', if any.
        if let Some(rel_slash) = text[1..].find('/') {
            let slash_pos = 1 + rel_slash;
            result.push(Span::styled("~".to_owned(), style));
            split_into_spans(&text[slash_pos..], style, &mut result);
        } else {
            // Bare "~" or "~something" with no slash — emit as a single segment.
            result.push(Span::styled(text.to_owned(), style));
        }
    } else {
        split_into_spans(text, style, &mut result);
    }

    result
}

/// Append spans from `path` to `result`, emitting each `/` separator and each
/// directory name as a separate span.  Handles absolute paths (starting with
/// `/`) as well as relative paths.
fn split_into_spans(path: &str, style: ratatui::style::Style, result: &mut Vec<Span<'static>>) {
    let mut remaining = path;
    while !remaining.is_empty() {
        if remaining.starts_with('/') {
            result.push(Span::styled("/".to_owned(), style));
            remaining = &remaining[1..];
        } else if let Some(slash_pos) = remaining.find('/') {
            result.push(Span::styled(remaining[..slash_pos].to_owned(), style));
            remaining = &remaining[slash_pos..];
        } else {
            result.push(Span::styled(remaining.to_owned(), style));
            break;
        }
    }
}

/// Expand `stdout` through bash prompt processing and return pre-tagged spans.
///
/// This is the common conversion used whenever a custom widget command
/// completes successfully: the raw stdout string is passed through
/// [`expand_prompt_through_bash`] and each resulting span is wrapped in a
/// [`TaggedSpan`] with [`Tag::Ps1Prompt`].
fn stdout_to_tagged_spans(stdout: String) -> Vec<TaggedSpan<'static>> {
    stdout_to_tagged_spans_with_tag(stdout, Tag::Ps1Prompt)
}

fn stdout_to_tagged_spans_with_tag(stdout: String, tag: Tag) -> Vec<TaggedSpan<'static>> {
    expand_prompt_through_bash(stdout)
        .unwrap_or_default()
        .into_iter()
        .flat_map(|line| {
            line.spans
                .into_iter()
                .map(|span| TaggedSpan::new(span, tag))
        })
        .collect()
}

/// Advance every [`PromptSegment::WidgetCustom`] segment whose child process
/// has exited from `Pending` to either `Done` or `Failed`.
///
/// This is the only step that needs mutable access to the prompt segments at
/// render time, so it is split out from [`format_prompt_line`] (which takes
/// an immutable slice) and called separately from
/// [`PromptManager::get_ps1_lines`].
fn advance_pending_widgets(segments: &mut [PromptSegment]) {
    for segment in segments.iter_mut() {
        if let PromptSegment::WidgetCustom { state, .. } = segment {
            let new_state: Option<WidgetCustomState> = match state {
                WidgetCustomState::Pending {
                    child,
                    command,
                    prev_output_cell,
                    ..
                } => match child.try_wait() {
                    Ok(Some(status)) => Some(collect_and_finalize(
                        &mut *child,
                        status,
                        command,
                        prev_output_cell,
                    )),
                    Ok(None) => None,
                    Err(e) => {
                        log::error!("Custom prompt widget: try_wait error: {}", e);
                        Some(WidgetCustomState::Failed(WidgetFailure {
                            exit_code: None,
                            stdout: String::new(),
                            stderr: e.to_string(),
                        }))
                    }
                },
                _ => None,
            };
            if let Some(s) = new_state {
                *state = s;
            }
        }
    }
}

/// Convert a slice of [`PromptSegment`]s to a [`TaggedLine`] by resolving each
/// segment against `now` and attaching an appropriate [`Tag`] to each span.
///
/// `mouse_enabled` is used by [`PromptSegment::WidgetMouseMode`] to choose
/// between the enabled and disabled text.
///
/// Pending [`PromptSegment::WidgetCustom`] segments are not advanced here;
/// callers are expected to invoke [`advance_pending_widgets`] beforehand so
/// that this function can take an immutable slice.
fn format_prompt_line(
    segments: &[PromptSegment],
    now: &chrono::DateTime<chrono::Local>,
    mouse_enabled: bool,
    leader_active: bool,
) -> TaggedLine<'static> {
    let tagged_spans: Vec<TaggedSpan<'static>> = segments
        .iter()
        .flat_map(|segment| -> Vec<TaggedSpan<'static>> {
            match segment {
                PromptSegment::Static(span) => {
                    vec![TaggedSpan::new(span.clone(), Tag::Ps1Prompt)]
                }
                PromptSegment::Cwd(spans) => {
                    // Only selectable spans get a PromptCwdWidget(n) tag.
                    // A span is selectable when it is not a "/" separator, or
                    // when it is the very first span (the leading "/" of an
                    // absolute path).  Internal "/" separators get Ps1Prompt so
                    // they are rendered normally and never highlighted.
                    let selectable_count = spans
                        .iter()
                        .enumerate()
                        .filter(|(i, s)| s.content.as_ref() != "/" || *i == 0)
                        .count();
                    let mut sel_idx = 0usize;
                    let mut tagged: Vec<TaggedSpan<'static>> = Vec::with_capacity(spans.len());
                    for (i, span) in spans.iter().enumerate() {
                        let is_selectable = span.content.as_ref() != "/" || i == 0;
                        let tag = if is_selectable {
                            let t = Tag::Ps1PromptCwdWidget(selectable_count - 1 - sel_idx);
                            sel_idx += 1;
                            t
                        } else {
                            Tag::Ps1Prompt
                        };
                        tagged.push(TaggedSpan::new(span.clone(), tag));
                    }
                    tagged
                }
                PromptSegment::DynamicTime { strftime, style } => {
                    vec![TaggedSpan::new(
                        Span::styled(now.format(strftime).to_string(), *style),
                        Tag::Ps1PromptDynamicTime,
                    )]
                }
                PromptSegment::Animation(anim) => get_frame_spans(anim, now)
                    .iter()
                    .map(|span| TaggedSpan::new(span.clone(), Tag::Ps1PromptAnimation))
                    .collect(),
                PromptSegment::WidgetMouseMode {
                    enabled_text,
                    disabled_text,
                } => {
                    let tagged: &Vec<TaggedSpan<'static>> = if mouse_enabled {
                        enabled_text
                    } else {
                        disabled_text
                    };
                    tagged.clone()
                }
                PromptSegment::WidgetLeaderMode {
                    active_text,
                    inactive_text,
                } => {
                    let tagged: &Vec<TaggedSpan<'static>> = if leader_active {
                        active_text
                    } else {
                        inactive_text
                    };
                    tagged.clone()
                }
                PromptSegment::WidgetCopyBuffer { text } => text.clone(),
                PromptSegment::WidgetLastCommandDuration { text, base_style } => {
                    vec![TaggedSpan::new(
                        Span::styled(text.clone(), *base_style),
                        Tag::Ps1Prompt,
                    )]
                }
                PromptSegment::WidgetCustom { state, base_style } => {
                    let raw_spans = match state {
                        WidgetCustomState::Pending { placeholder, .. } => placeholder.clone(),
                        WidgetCustomState::Done(tagged_spans) => tagged_spans.clone(),
                        WidgetCustomState::Failed(failure) => failure.to_error_spans(),
                    };
                    // Apply the base prompt span style first; the widget
                    // output's own style overrides it (base_style.patch(span)).
                    raw_spans
                        .into_iter()
                        .map(|mut ts| {
                            ts.span.style = base_style.patch(ts.span.style);
                            ts
                        })
                        .collect()
                }
            }
        })
        .collect();
    TaggedLine::from(tagged_spans)
}

/// Return the pre-processed [`Span`]s for the current animation frame.
///
/// The frame index is derived from the wall-clock milliseconds in `now`.
/// When `ping_pong` is enabled the animation bounces: it plays forward to
/// the last frame and then reverses back to the first, rather than
/// wrapping around.
fn get_frame_spans<'a>(
    anim: &'a ProcessedAnimation,
    now: &chrono::DateTime<chrono::Local>,
) -> &'a [Span<'static>] {
    if anim.frames.is_empty() {
        return &[];
    }
    if anim.fps <= 0.0 {
        return &anim.frames[0];
    }
    let ms = now.timestamp_millis();
    let frame_duration_ms = (1000.0 / anim.fps) as i64;
    let tick = if frame_duration_ms > 0 {
        (ms / frame_duration_ms) as usize
    } else {
        0
    };
    let n = anim.frames.len();
    let frame_index = if anim.ping_pong && n > 1 {
        // Period: forward (n frames) + reverse (n-2 inner frames) = 2*(n-1)
        let period = 2 * (n - 1);
        let pos = tick % period;
        if pos < n { pos } else { period - pos }
    } else {
        tick % n
    };
    &anim.frames[frame_index]
}

/// Spawn a widget command as a child process with captured stdout and stderr.
///
/// Returns the [`std::process::Child`] on success, or a [`WidgetFailure`] if
/// the command list is empty or the process cannot be spawned.
///
/// `SIGCHLD` is expected to have been set to `SIG_DFL` by the caller before
/// `app::get_command` was invoked; this function does not touch signal
/// dispositions.
fn spawn_widget_child(command: &[String]) -> Result<std::process::Child, WidgetFailure> {
    use std::process::Stdio;
    let (prog, args) = match command.split_first() {
        Some(parts) => parts,
        None => {
            let msg = "spawn_widget_child: empty command".to_string();
            log::warn!("{}", msg);
            return Err(WidgetFailure {
                exit_code: None,
                stdout: String::new(),
                stderr: msg,
            });
        }
    };
    std::process::Command::new(prog)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| {
            log::error!(
                "Custom prompt widget: failed to spawn command {:?}: {}",
                command,
                e
            );
            WidgetFailure {
                exit_code: None,
                stdout: String::new(),
                stderr: e.to_string(),
            }
        })
}

/// Read remaining output from a child that has already exited (after
/// [`try_wait`] returned `Some(status)`) and build the final [`WidgetCustomState`].
///
/// The exit `status` must come from the `try_wait` call so that we do not call
/// `wait` a second time (which would fail because the zombie has already been
/// reaped by `try_wait` on Unix).
fn collect_and_finalize(
    child: &mut std::process::Child,
    status: std::process::ExitStatus,
    command: &[String],
    prev_output: &Arc<Mutex<Vec<TaggedSpan<'static>>>>,
) -> WidgetCustomState {
    use std::io::Read;
    let mut stdout_buf = Vec::new();
    let mut stderr_buf = Vec::new();
    if let Some(mut out) = child.stdout.take() {
        if let Err(e) = out.read_to_end(&mut stdout_buf) {
            log::warn!(
                "Custom prompt widget {:?}: error reading stdout: {}",
                command,
                e
            );
        }
    }
    if let Some(mut err) = child.stderr.take() {
        if let Err(e) = err.read_to_end(&mut stderr_buf) {
            log::warn!(
                "Custom prompt widget {:?}: error reading stderr: {}",
                command,
                e
            );
        }
    }
    let stdout = String::from_utf8_lossy(&stdout_buf).into_owned();
    let stderr = String::from_utf8_lossy(&stderr_buf).trim().to_string();
    log::info!("Custom prompt widget {:?} exited with {}", command, status);
    log::debug!("Custom prompt widget stdout: {}", stdout);
    log::debug!("Custom prompt widget stderr: {}", stderr);
    if status.success() {
        let process_output = stdout_to_tagged_spans(stdout);
        *prev_output.lock().unwrap() = process_output.clone();
        WidgetCustomState::Done(process_output)
    } else {
        log::warn!(
            "Custom prompt widget {:?} failed with {}; stderr: {}",
            command,
            status,
            stderr
        );
        WidgetCustomState::Failed(WidgetFailure {
            exit_code: status.code(),
            stdout,
            stderr,
        })
    }
}

impl PromptManager {
    pub fn new(
        unfinished_from_prev_command: bool,
        animations: &[PromptAnimation],
        widgets: &[PromptWidget],
        last_app_closed_at: Option<std::time::Instant>,
    ) -> Self {
        if unfinished_from_prev_command {
            // If the previous command was unfinished, use a simple prompt to avoid confusion

            let style = ratatui::style::Style::default()
                .bg(ratatui::style::Color::Red)
                .fg(ratatui::style::Color::Black);

            let prompt = vec![
                vec![
                    PromptSegment::Static(Span::styled(
                        "Bash needs more input to finish the command. ",
                        style,
                    )),
                    PromptSegment::Static(Span::styled(
                        "Flyline thought the previous command was complete. ",
                        style,
                    )),
                    PromptSegment::Static(Span::styled(
                        "Please open an issue on GitHub with the command that caused this message. ",
                        style,
                    )),
                ],
                vec![PromptSegment::Static(Span::raw("> "))],
            ];

            PromptManager {
                prompt,
                prompt_final: None,
                rprompt: vec![],
                rprompt_final: None,
                fill_span: vec![PromptSegment::Static(Span::raw(" "))],
                fill_span_final: None,
                construction_time: chrono::Local::now(),
                cwd: String::new(),
            }
        } else {
            const PS1_DEFAULT: &str = "bad ps1> ";

            // Process each animation frame through expand_prompt_through_bash
            // so frames are resolved to plain Spans only.
            let processed_animations: Vec<ProcessedAnimation> = animations
                .iter()
                .map(|anim| {
                    let frames: Vec<Vec<Span<'static>>> = anim
                        .frames
                        .iter()
                        .map(|raw_frame| {
                            expand_prompt_through_bash(raw_frame.clone())
                                .unwrap_or_default()
                                .into_iter()
                                .flat_map(|line| line.spans)
                                .collect()
                        })
                        .collect();
                    ProcessedAnimation {
                        name: anim.name.clone(),
                        fps: anim.fps,
                        frames,
                        ping_pong: anim.ping_pong,
                    }
                })
                .collect();

            log::debug!("Animation count: {}", processed_animations.len());

            // A single builder is shared across all prompt variables so that
            // placeholder IDs are unique.  Animations and widgets are passed in
            // so that expand_span_to_segments can produce the right segments.
            // Widget segments (including process spawning) are created lazily
            // inside split_static_span_by_widgets as each widget name is found.
            log::debug!("Widget count: {}", widgets.len());
            let cwd = bash_funcs::get_cwd();
            let home = bash_funcs::get_envvar_value("HOME");
            log::debug!("CWD for prompt detection: {:?}, HOME: {:?}", cwd, home);
            let mut builder = PromptStringBuilder::new(processed_animations, widgets)
                .with_cwd(cwd.clone(), home)
                .with_last_app_closed_at(last_app_closed_at);

            // Read the raw PS1 env var so we can intercept time format codes
            // before handing the string to decode_prompt_string.
            let ps1_raw = bash_funcs::get_envvar_value("PS1");

            let ps1 = ps1_raw
                .and_then(|raw| builder.expand_prompt_string(raw))
                .unwrap_or_else(|| {
                    log::warn!("Failed to parse PS1, defaulting to '{}'", PS1_DEFAULT);
                    vec![vec![PromptSegment::Static(Span::raw(PS1_DEFAULT))]]
                });

            // Examples:
            // RPS1='\e[01;32m\t\e[0m'
            // export RPROMPT='\e[01;32m\D{%H:%M:%S}\e[0m'
            let rps1 = bash_funcs::get_envvar_value("RPS1")
                .or_else(|| bash_funcs::get_envvar_value("RPROMPT"))
                .and_then(|raw| builder.expand_prompt_string(raw))
                .unwrap_or_default();

            log::debug!("Parsed RPS1: {:?}", rps1);

            let fill_span = bash_funcs::get_envvar_value("PS1_FILL")
                .map(|raw| {
                    if raw.is_empty() {
                        vec![]
                    } else {
                        builder
                            .expand_prompt_string(raw)
                            .and_then(|lines| lines.into_iter().next())
                            .unwrap_or_else(|| vec![PromptSegment::Static(Span::raw(" "))])
                    }
                })
                .unwrap_or_else(|| vec![PromptSegment::Static(Span::raw(" "))]);

            let ps1_final_raw = bash_funcs::get_envvar_value("PS1_FINAL");
            let ps1_final = ps1_final_raw.and_then(|raw| builder.expand_prompt_string(raw));

            let rps1_final = bash_funcs::get_envvar_value("RPS1_FINAL")
                .and_then(|raw| builder.expand_prompt_string(raw));

            let fill_span_final = bash_funcs::get_envvar_value("PS1_FILL_FINAL").map(|raw| {
                if raw.is_empty() {
                    vec![]
                } else {
                    builder
                        .expand_prompt_string(raw)
                        .and_then(|lines| lines.into_iter().next())
                        .unwrap_or_else(|| vec![PromptSegment::Static(Span::raw(" "))])
                }
            });

            PromptManager {
                prompt: ps1,
                prompt_final: ps1_final,
                rprompt: rps1,
                rprompt_final: rps1_final,
                fill_span,
                fill_span_final,
                construction_time: chrono::Local::now(),
                cwd,
            }
        }
    }

    pub fn get_ps1_lines(
        &mut self,
        show_animations: bool,
        mouse_enabled: bool,
        leader_active: bool,
        is_running: bool,
    ) -> (
        Vec<TaggedLine<'static>>,
        Vec<TaggedLine<'static>>,
        TaggedLine<'static>,
    ) {
        use chrono::Local;
        let now = if show_animations {
            Local::now()
        } else {
            self.construction_time
        };

        let prompt_src = if is_running {
            &mut self.prompt
        } else {
            self.prompt_final.as_mut().unwrap_or(&mut self.prompt)
        };

        let rprompt_src = if is_running {
            &mut self.rprompt
        } else {
            self.rprompt_final.as_mut().unwrap_or(&mut self.rprompt)
        };

        let fill_span_src = if is_running {
            &mut self.fill_span
        } else {
            self.fill_span_final.as_mut().unwrap_or(&mut self.fill_span)
        };

        let formatted_prompt: Vec<TaggedLine<'static>> = prompt_src
            .iter_mut()
            .map(|line| {
                advance_pending_widgets(line);
                format_prompt_line(line, &now, mouse_enabled, leader_active)
            })
            .collect();

        let formatted_rprompt: Vec<TaggedLine<'static>> = rprompt_src
            .iter_mut()
            .map(|line| {
                advance_pending_widgets(line);
                format_prompt_line(line, &now, mouse_enabled, leader_active)
            })
            .collect();

        advance_pending_widgets(fill_span_src);
        let formatted_fill = format_prompt_line(fill_span_src, &now, mouse_enabled, leader_active);

        (formatted_prompt, formatted_rprompt, formatted_fill)
    }

    /// Return the number of CWD display segments in the left prompt.
    ///
    /// This is the count of *selectable* path spans tagged with
    /// [`Tag::PromptCwdWidget`]: every non-`"/"` span, plus the leading `"/"` of
    /// an absolute path (index 0).  Internal `"/"` separator spans are not
    /// counted.  Returns 0 when no CWD segments are present.
    pub fn cwd_display_segment_count(&self) -> usize {
        self.prompt
            .iter()
            .flat_map(|line| line.iter())
            .find_map(|seg| {
                if let PromptSegment::Cwd(spans) = seg {
                    let count = spans
                        .iter()
                        .enumerate()
                        .filter(|(i, s)| s.content.as_ref() != "/" || *i == 0)
                        .count();
                    Some(count)
                } else {
                    None
                }
            })
            .unwrap_or(0)
    }

    /// Return the filesystem path corresponding to the CWD segment at `index`.
    ///
    /// Index 0 is the rightmost (current) directory; each increment steps one
    /// level toward the root.  Returns `None` when no CWD is available.
    pub fn cwd_path_for_index(&self, index: usize) -> Option<String> {
        if self.cwd.is_empty() {
            return None;
        }
        let components: Vec<_> = std::path::Path::new(&self.cwd).components().collect();
        if components.is_empty() {
            return None;
        }
        let keep = components.len().saturating_sub(index);
        if keep == 0 {
            // Clamp to root.
            let root: std::path::PathBuf = components[..1].iter().collect();
            return Some(root.to_string_lossy().into_owned());
        }
        let result: std::path::PathBuf = components[..keep].iter().collect();
        Some(result.to_string_lossy().into_owned())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::settings::PromptWidgetCustom;
    use chrono::TimeZone;

    fn fixed_time(ms: i64) -> chrono::DateTime<chrono::Local> {
        chrono::Local.timestamp_millis_opt(ms).unwrap()
    }

    /// Build a `ProcessedAnimation` where each frame is a single span,
    /// suitable for unit-testing without any bash FFI calls.
    fn make_processed_anim(name: &str, fps: f64, frames: &[&str]) -> ProcessedAnimation {
        ProcessedAnimation {
            name: name.to_string(),
            fps,
            frames: frames
                .iter()
                .map(|s| vec![Span::raw(s.to_string())])
                .collect(),
            ping_pong: false,
        }
    }

    /// Build a ping-pong `ProcessedAnimation` for unit-testing.
    fn make_ping_pong_anim(name: &str, fps: f64, frames: &[&str]) -> ProcessedAnimation {
        ProcessedAnimation {
            name: name.to_string(),
            fps,
            frames: frames
                .iter()
                .map(|s| vec![Span::raw(s.to_string())])
                .collect(),
            ping_pong: true,
        }
    }

    /// Extract the text content of the first span in a frame slice.
    fn first_span_content(spans: &[Span<'static>]) -> std::borrow::Cow<'static, str> {
        assert!(!spans.is_empty(), "expected at least one span");
        spans[0].content.clone()
    }

    // --- get_frame_spans (frame index selection) --------------------------

    #[test]
    fn test_get_frame_spans_empty_frames() {
        let anim = make_processed_anim("A", 10.0, &[]);
        assert!(get_frame_spans(&anim, &fixed_time(0)).is_empty());
    }

    #[test]
    fn test_get_frame_spans_single_frame() {
        let anim = make_processed_anim("A", 10.0, &["only"]);
        // Always returns the single frame regardless of time.
        let spans_at_0 = get_frame_spans(&anim, &fixed_time(0));
        assert_eq!(first_span_content(spans_at_0), "only");

        let spans_at_999 = get_frame_spans(&anim, &fixed_time(999));
        assert_eq!(first_span_content(spans_at_999), "only");
    }

    #[test]
    fn test_get_frame_spans_cycles() {
        // fps=10 → 100 ms per frame
        let anim = make_processed_anim("A", 10.0, &["f0", "f1", "f2"]);
        let frame_content = |ms| {
            let spans = get_frame_spans(&anim, &fixed_time(ms));
            first_span_content(spans)
        };
        assert_eq!(frame_content(0), "f0");
        assert_eq!(frame_content(100), "f1");
        assert_eq!(frame_content(200), "f2");
        assert_eq!(frame_content(300), "f0"); // wraps
    }

    #[test]
    fn test_get_frame_spans_ping_pong_three_frames() {
        // fps=10 → 100 ms per frame; frames: f0, f1, f2
        // ping-pong sequence: f0, f1, f2, f1, f0, f1, f2, ...  (period = 4)
        let anim = make_ping_pong_anim("A", 10.0, &["f0", "f1", "f2"]);
        let frame_content = |ms| {
            let spans = get_frame_spans(&anim, &fixed_time(ms));
            first_span_content(spans)
        };
        assert_eq!(frame_content(0), "f0"); // tick 0
        assert_eq!(frame_content(100), "f1"); // tick 1
        assert_eq!(frame_content(200), "f2"); // tick 2 – last frame
        assert_eq!(frame_content(300), "f1"); // tick 3 – reversed
        assert_eq!(frame_content(400), "f0"); // tick 4 – wraps back to start
        assert_eq!(frame_content(500), "f1"); // tick 5 – forward again
    }

    #[test]
    fn test_get_frame_spans_ping_pong_two_frames() {
        // fps=10 → 100 ms per frame; frames: f0, f1
        // period = 2*(2-1) = 2 → same as normal cycling for two frames
        let anim = make_ping_pong_anim("A", 10.0, &["f0", "f1"]);
        let frame_content = |ms| {
            let spans = get_frame_spans(&anim, &fixed_time(ms));
            first_span_content(spans)
        };
        assert_eq!(frame_content(0), "f0");
        assert_eq!(frame_content(100), "f1");
        assert_eq!(frame_content(200), "f0"); // wraps
    }

    #[test]
    fn test_get_frame_spans_ping_pong_single_frame() {
        // A single-frame ping-pong animation should always return that frame.
        let anim = make_ping_pong_anim("A", 10.0, &["only"]);
        for ms in [0, 100, 200, 999] {
            let spans = get_frame_spans(&anim, &fixed_time(ms));
            assert_eq!(first_span_content(spans), "only");
        }
    }

    #[test]
    fn test_get_frame_spans_frozen_when_disabled() {
        // When disable_animations is true the caller passes construction_time
        // for every render.  Verify that the same time always yields the same frame.
        let anim = make_processed_anim("A", 10.0, &["f0", "f1", "f2"]);
        let frozen = fixed_time(50); // 50 ms → frame 0 (0..100 ms range)
        assert_eq!(first_span_content(get_frame_spans(&anim, &frozen)), "f0");
        assert_eq!(first_span_content(get_frame_spans(&anim, &frozen)), "f0");
    }

    // --- split_span_by_animations (now tested via expand_span_to_segments) ---

    #[test]
    fn test_expand_span_animation_not_present() {
        let anim = make_processed_anim("SPIN", 10.0, &["f0"]);
        let builder = PromptStringBuilder::new(vec![anim], &[]);
        let span = Span::raw("no spinner here");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "no spinner here"),
            _ => panic!("expected Static"),
        }
    }

    #[test]
    fn test_expand_span_animation_name_only() {
        let anim = make_processed_anim("SPIN", 10.0, &["f0", "f1"]);
        let builder = PromptStringBuilder::new(vec![anim], &[]);
        let span = Span::raw("SPIN");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Animation(a) => assert_eq!(a.name, "SPIN"),
            _ => panic!("expected Animation"),
        }
    }

    #[test]
    fn test_expand_span_animation_name_surrounded_by_text() {
        let anim = make_processed_anim("SPIN", 10.0, &["f0"]);
        let builder = PromptStringBuilder::new(vec![anim], &[]);
        let span = Span::raw("before SPIN after");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "before "),
            _ => panic!("expected Static at index 0"),
        }
        match &segs[1] {
            PromptSegment::Animation(a) => assert_eq!(a.name, "SPIN"),
            _ => panic!("expected Animation at index 1"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content, " after"),
            _ => panic!("expected Static at index 2"),
        }
    }

    // --- format_prompt_line (Animation rendering) ----------------------------

    #[test]
    fn test_format_prompt_line_animation_substitution() {
        // At t=0 ms, fps=10 → 100 ms/frame → frame 0 ("f0").
        let anim = make_processed_anim("SPIN", 10.0, &["f0", "f1"]);
        let segments = vec![
            PromptSegment::Static(Span::raw("before ")),
            PromptSegment::Animation(Box::new(anim)),
            PromptSegment::Static(Span::raw(" after")),
        ];
        let now = fixed_time(0);
        let line = format_prompt_line(&segments, &now, false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "before f0 after");
    }

    #[test]
    fn test_format_prompt_line_animation_frame_advances() {
        // At t=100 ms, fps=10 → frame 1 ("f1").
        let anim = make_processed_anim("SPIN", 10.0, &["f0", "f1"]);
        let segments = vec![PromptSegment::Animation(Box::new(anim))];
        let line = format_prompt_line(&segments, &fixed_time(100), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "f1");
    }

    #[test]
    fn test_format_prompt_line_animation_tag() {
        let anim = make_processed_anim("SPIN", 10.0, &["f0"]);
        let segments = vec![PromptSegment::Animation(Box::new(anim))];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert!(
            line.spans.iter().all(
                |s| s.tag == crate::content_builder::SpanTag::Constant(Tag::Ps1PromptAnimation)
            )
        );
    }

    #[test]
    fn test_animation_frame_style_overrides_base_style() {
        // When the prompt span has a base style and the animation frame has its
        // own style, the frame style should override the base style.
        // `base.patch(frame)` means frame wins.
        let base_style = Style::default().fg(Color::Blue);
        let frame_style = Style::default().fg(Color::Red);
        let mut anim = make_processed_anim("SPIN", 10.0, &["f0"]);
        anim.frames[0][0].style = frame_style;
        let anim = anim.patch_style(base_style);
        assert_eq!(
            anim.frames[0][0].style.fg,
            Some(Color::Red),
            "frame fg should override base fg"
        );
    }

    #[test]
    fn test_animation_base_style_fills_unset_fields() {
        // When the animation frame leaves a style field unset, the base style
        // should fill it in.
        let base_style = Style::default().fg(Color::Green);
        let mut anim = make_processed_anim("SPIN", 10.0, &["f0"]);
        anim.frames[0][0].style = Style::default();
        let anim = anim.patch_style(base_style);
        assert_eq!(
            anim.frames[0][0].style.fg,
            Some(Color::Green),
            "base fg should be used when frame fg is unset"
        );
    }

    // --- format_prompt_line (DynamicTime rendering) --------------------------

    #[test]
    fn test_format_prompt_line_dynamic_time() {
        // Use a fixed time to produce a predictable formatted string.  The actual
        // HH:MM:SS value is timezone-dependent, so we compute the expected value
        // with the same `now` and format string rather than hard-coding a literal.
        let now = fixed_time(0);
        let formatted_time = now.format("%H:%M:%S").to_string();

        let segments = vec![
            PromptSegment::Static(Span::raw("[")),
            PromptSegment::DynamicTime {
                strftime: "%H:%M:%S".to_string(),
                style: ratatui::style::Style::default(),
            },
            PromptSegment::Static(Span::raw("]")),
        ];
        let line = format_prompt_line(&segments, &now, false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, format!("[{}]", formatted_time));
    }

    // --- expand_span_to_segments (time-code splitting) -----------------------

    #[test]
    fn test_expand_span_to_segments_no_placeholders() {
        let builder = PromptStringBuilder::new(vec![], &[]);
        let span = Span::raw("hello world");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "hello world"),
            _ => panic!("expected Static"),
        }
    }

    #[test]
    fn test_expand_span_to_segments_single_placeholder() {
        let mut builder = PromptStringBuilder::new(vec![], &[]);
        let id = builder.next_id();
        builder.time_map.insert(id.clone(), "%H:%M:%S".to_string());

        let span = Span::raw(id.clone());
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::DynamicTime { strftime, .. } => {
                assert_eq!(strftime, "%H:%M:%S");
            }
            _ => panic!("expected DynamicTime"),
        }
    }

    #[test]
    fn test_expand_span_to_segments_placeholder_surrounded_by_text() {
        let mut builder = PromptStringBuilder::new(vec![], &[]);
        let id = builder.next_id();
        builder.time_map.insert(id.clone(), "%H:%M".to_string());

        let span = Span::raw(format!("prefix {} suffix", id));
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "prefix "),
            _ => panic!("expected Static at index 0"),
        }
        match &segs[1] {
            PromptSegment::DynamicTime { strftime, .. } => assert_eq!(strftime, "%H:%M"),
            _ => panic!("expected DynamicTime at index 1"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content, " suffix"),
            _ => panic!("expected Static at index 2"),
        }
    }

    // --- find_cwd_in_span ----------------------------------------------------

    #[test]
    fn test_find_cwd_tilde_only() {
        let text = "otherpartofheprompt~:$";
        let cwd = "/home/foo";
        let result = find_cwd_in_span(text, cwd, Some("/home/foo"));
        let cwd_range = result.expect("should find a match");
        assert_eq!(&text[cwd_range], "~");
    }

    #[test]
    fn test_find_cwd_tilde_path() {
        let text = "otherpartofheprompt~/qwe/try:$";
        let cwd = "/home/foo/qwe/try";
        let result = find_cwd_in_span(text, cwd, Some("/home/foo"));
        let cwd_range = result.expect("should find a match");
        assert_eq!(&text[cwd_range], "~/qwe/try");
    }

    #[test]
    fn test_find_cwd_partial_path_no_tilde() {
        let text = "otherpartofhepromptqwe/try/ooh:$";
        let cwd = "/home/foo/qwe/try/ooh";
        let result = find_cwd_in_span(text, cwd, Some("/home/foo"));
        let cwd_range = result.expect("should find a match");
        assert_eq!(&text[cwd_range], "qwe/try/ooh");
    }

    #[test]
    fn test_find_cwd_partial_path_trailing_slash() {
        let text = "otherpartofheprompt try/ooh/lkj/:$";
        let cwd = "/home/qwe/try/ooh/lkj";
        let result = find_cwd_in_span(text, cwd, None);
        let cwd_range = result.expect("should find a match");
        assert_eq!(&text[cwd_range], "try/ooh/lkj/");
    }

    #[test]
    fn test_find_cwd_full() {
        let text = "otherpartofheprompt /home/qwe/try/ooh/lkj/:$";
        let cwd = "/home/qwe/try/ooh/lkj";
        let result = find_cwd_in_span(text, cwd, None);
        let cwd_range = result.expect("should find a match");
        assert_eq!(&text[cwd_range], "/home/qwe/try/ooh/lkj/");
    }

    #[test]
    fn test_find_cwd_truncated_segment_prefix() {
        let text = "uncated/foo/bar";
        let cwd = "/home/user/truncated/foo/bar";
        let range = find_cwd_in_span(text, cwd, None).expect("should find range");
        assert_eq!(&text[range], "uncated/foo/bar");
    }

    #[test]
    fn test_find_cwd_ellipsis_dots_prefix() {
        let text = ".../foo/bar";
        let cwd = "/a/b/c/foo/bar";
        let range = find_cwd_in_span(text, cwd, None).expect("should find range");
        assert_eq!(&text[range], ".../foo/bar");
    }

    #[test]
    fn test_find_cwd_unicode_ellipsis_prefix() {
        let text = "…/foo/bar";
        let cwd = "/a/b/c/foo/bar";
        let range = find_cwd_in_span(text, cwd, None).expect("should find range");
        assert_eq!(&text[range], "…/foo/bar");
    }

    #[test]
    fn test_find_cwd_ellipsis_then_truncated_segment() {
        let text = "...uncated/foo/bar";
        let cwd = "/home/user/truncated/foo/bar";
        let range = find_cwd_in_span(text, cwd, None).expect("should find range");
        assert_eq!(&text[range], "...uncated/foo/bar");
    }

    #[test]
    fn test_find_cwd_requires_current_folder_name() {
        let text = "prefix ~/foo $";
        let cwd = "/home/user/foo/bar";
        assert_eq!(find_cwd_in_span(text, cwd, Some("/home/user")), None);
    }

    // --- split_static_span_by_cwd with truncated prefix ----------------------

    #[test]
    fn test_split_span_ellipsis_then_truncated_segment_becomes_cwd() {
        let span = Span::raw("...uncated/foo/bar");
        let cwd = "/home/user/truncated/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, "...uncated/foo/bar");
                assert_eq!(spans[0].content.as_ref(), "...uncated");
            }
            _ => panic!("expected Cwd segment"),
        }
    }

    #[test]
    fn test_split_span_prefix_ellipsis_truncated_segment_in_larger_prompt() {
        let span = Span::raw("host ...uncated/foo/bar $ ");
        let cwd = "/home/user/truncated/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content.as_ref(), "host "),
            _ => panic!("expected Static"),
        }
        match &segs[1] {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, "...uncated/foo/bar");
                assert_eq!(spans[0].content.as_ref(), "...uncated");
            }
            _ => panic!("expected Cwd"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content.as_ref(), " $ "),
            _ => panic!("expected Static"),
        }
    }

    #[test]
    fn test_split_span_ellipsis_prefix_becomes_cwd() {
        let span = Span::raw(".../foo/bar");
        let cwd = "/a/b/c/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, ".../foo/bar");
                assert_eq!(spans[0].content.as_ref(), "...");
            }
            _ => panic!("expected Cwd segment"),
        }
    }

    #[test]
    fn test_split_span_unicode_ellipsis_prefix_becomes_cwd() {
        let span = Span::raw("…/foo/bar");
        let cwd = "/a/b/c/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Cwd(spans) => {
                assert_eq!(spans[0].content.as_ref(), "…");
            }
            _ => panic!("expected Cwd segment"),
        }
    }

    #[test]
    fn test_split_span_truncated_segment_prefix_becomes_cwd() {
        let span = Span::raw("uncated/foo/bar");
        let cwd = "/home/user/truncated/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, "uncated/foo/bar");
                assert_eq!(spans[0].content.as_ref(), "uncated");
            }
            _ => panic!("expected Cwd segment"),
        }
    }

    #[test]
    fn test_split_span_ellipsis_in_larger_prompt() {
        let span = Span::raw("user@host .../foo/bar $ ");
        let cwd = "/a/b/c/foo/bar";
        let segs = split_static_span_by_cwd(span, cwd, None);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content.as_ref(), "user@host "),
            _ => panic!("expected Static"),
        }
        match &segs[1] {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, ".../foo/bar");
                assert_eq!(spans[0].content.as_ref(), "...");
            }
            _ => panic!("expected Cwd"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content.as_ref(), " $ "),
            _ => panic!("expected Static"),
        }
    }

    // --- format_prompt_line tagging for truncated-prefix CWD -----------------

    #[test]
    fn test_format_prompt_line_ellipsis_prefix_is_selectable() {
        // ".../foo/bar" → ["...", "/", "foo", "/", "bar"]
        // Selectable spans: "..." (i=0, first), "foo", "bar" → 3 total
        // Indices: "..." → 2, "foo" → 1, "bar" → 0
        let spans = split_cwd_text_into_spans(".../foo/bar", ratatui::style::Style::default());
        let segments = vec![PromptSegment::Cwd(spans)];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 5);
        // "..." → PromptCwdWidget(2)
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(2))
        );
        // "/" separator → Ps1Prompt (not selectable)
        assert_eq!(
            line.spans[1].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1Prompt)
        );
        // "foo" → PromptCwdWidget(1)
        assert_eq!(
            line.spans[2].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(1))
        );
        // "/" separator → Ps1Prompt
        assert_eq!(
            line.spans[3].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1Prompt)
        );
        // "bar" → PromptCwdWidget(0)
        assert_eq!(
            line.spans[4].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(0))
        );
    }

    #[test]
    fn test_format_prompt_line_truncated_segment_is_selectable() {
        // "uncated/foo/bar" → ["uncated", "/", "foo", "/", "bar"]
        // "uncated" is at i=0 → selectable
        let spans = split_cwd_text_into_spans("uncated/foo/bar", ratatui::style::Style::default());
        let segments = vec![PromptSegment::Cwd(spans)];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 5);
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(2))
        );
        assert_eq!(
            line.spans[2].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(1))
        );
        assert_eq!(
            line.spans[4].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(0))
        );
    }

    // --- expand_span_to_segments with CWD detection --------------------------

    #[test]
    fn test_expand_span_cwd_tilde_path() {
        let builder = PromptStringBuilder::new(vec![], &[]).with_cwd(
            "/home/foo/qwe/try/ooh/lkj".to_string(),
            Some("/home/foo".to_string()),
        );
        let span = Span::raw("otherpartofheprompt~/qwe/try/ooh/lkj:$");
        let segs = builder.expand_span_to_segments(span);
        let cwd_seg = segs.iter().find(|s| matches!(s, PromptSegment::Cwd(_)));
        match cwd_seg.expect("expected a Cwd segment") {
            PromptSegment::Cwd(spans) => {
                let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
                assert_eq!(text, "~/qwe/try/ooh/lkj");
            }
            _ => unreachable!(),
        }
    }

    // #[test]
    // fn test_expand_span_cwd_partial_no_tilde() {
    //     let builder = PromptStringBuilder::new(vec![], &[]).with_cwd(
    //         "/home/foo/qwe/try/ooh/lkj".to_string(),
    //         Some("/home/foo".to_string()),
    //     );
    //     let span = Span::raw("otherpartofhepromptqwe/try/ooh:$");
    //     let segs = builder.expand_span_to_segments(span);
    //     let cwd_seg = segs.iter().find(|s| matches!(s, PromptSegment::Cwd(_)));
    //     match cwd_seg.expect("expected a Cwd segment") {
    //         PromptSegment::Cwd(spans) => {
    //             let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
    //             assert_eq!(text, "qwe/try/ooh");
    //         }
    //         _ => unreachable!(),
    //     }
    // }

    // --- split_cwd_text_into_spans -------------------------------------------

    fn span_contents<'a>(spans: &'a [Span<'static>]) -> Vec<&'a str> {
        spans.iter().map(|s| s.content.as_ref()).collect()
    }

    #[test]
    fn test_split_cwd_tilde_only() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("~", style);
        assert_eq!(span_contents(&spans), vec!["~"]);
    }

    #[test]
    fn test_split_cwd_tilde_with_segments() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("~/foo/bar/baz", style);
        assert_eq!(
            span_contents(&spans),
            vec!["~", "/", "foo", "/", "bar", "/", "baz"]
        );
    }

    #[test]
    fn test_split_cwd_absolute_path() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("/home/foo/bar", style);
        assert_eq!(
            span_contents(&spans),
            vec!["/", "home", "/", "foo", "/", "bar"]
        );
    }

    #[test]
    fn test_split_cwd_relative_path() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("qwe/try/ooh", style);
        assert_eq!(span_contents(&spans), vec!["qwe", "/", "try", "/", "ooh"]);
    }

    #[test]
    fn test_split_cwd_single_segment() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("/home", style);
        assert_eq!(span_contents(&spans), vec!["/", "home"]);
    }

    #[test]
    fn test_split_cwd_trailing_slash() {
        let style = ratatui::style::Style::default();
        let spans = split_cwd_text_into_spans("~/foo/", style);
        // trailing slash becomes its own "/" span
        assert_eq!(span_contents(&spans), vec!["~", "/", "foo", "/"]);
    }

    // --- format_prompt_line (CWD tagging) ------------------------------------

    #[test]
    fn test_format_prompt_line_cwd_tags() {
        // "~/foo/bar" → 5 spans ["~", "/", "foo", "/", "bar"];
        // selectable spans are "~", "foo", "bar" (3 total).
        // "~" is leftmost selectable → index 2, "foo" → index 1, "bar" → index 0.
        // "/" separators get Ps1Prompt (not selectable).
        let spans = split_cwd_text_into_spans("~/foo/bar", ratatui::style::Style::default());
        let segments = vec![PromptSegment::Cwd(spans)];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 5);
        // "~" → index 2
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(2))
        );
        // "/" separator → Ps1Prompt
        assert_eq!(
            line.spans[1].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1Prompt)
        );
        // "foo" → index 1
        assert_eq!(
            line.spans[2].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(1))
        );
        // "/" separator → Ps1Prompt
        assert_eq!(
            line.spans[3].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1Prompt)
        );
        // "bar" is rightmost → index 0
        assert_eq!(
            line.spans[4].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(0))
        );
    }

    #[test]
    fn test_format_prompt_line_cwd_single_segment_tag() {
        let spans = split_cwd_text_into_spans("~", ratatui::style::Style::default());
        let segments = vec![PromptSegment::Cwd(spans)];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 1);
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptCwdWidget(0))
        );
    }

    #[test]
    fn test_format_prompt_line_dynamic_time_tag() {
        let segments = vec![PromptSegment::DynamicTime {
            strftime: "%H:%M".to_string(),
            style: ratatui::style::Style::default(),
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 1);
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1PromptDynamicTime)
        );
    }

    #[test]
    fn test_format_prompt_line_static_tag() {
        let segments = vec![PromptSegment::Static(Span::raw("$ "))];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans.len(), 1);
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::Ps1Prompt)
        );
    }

    // --- cwd_path_for_index / cwd_display_segment_count -------------------

    /// Build a minimal `PromptManager` for testing the CWD helper methods.
    fn make_pm_with_cwd(cwd: &str, cwd_text: &str) -> PromptManager {
        let spans = split_cwd_text_into_spans(cwd_text, ratatui::style::Style::default());
        PromptManager {
            prompt: vec![vec![PromptSegment::Cwd(spans)]],
            prompt_final: None,
            rprompt: vec![],
            rprompt_final: None,
            fill_span: vec![],
            fill_span_final: None,
            construction_time: chrono::Local::now(),
            cwd: cwd.to_string(),
        }
    }

    #[test]
    fn test_cwd_display_segment_count_no_cwd() {
        let pm = PromptManager {
            prompt: vec![vec![PromptSegment::Static(Span::raw("$ "))]],
            prompt_final: None,
            rprompt: vec![],
            rprompt_final: None,
            fill_span: vec![],
            fill_span_final: None,
            construction_time: chrono::Local::now(),
            cwd: String::new(),
        };
        assert_eq!(pm.cwd_display_segment_count(), 0);
    }

    #[test]
    fn test_cwd_display_segment_count_three_segments() {
        let pm = make_pm_with_cwd("/home/foo/bar", "~/bar");
        // "~/bar" splits into ["~", "/bar"] → 2 display segments
        assert_eq!(pm.cwd_display_segment_count(), 2);
    }

    #[test]
    fn test_cwd_display_segment_count_absolute_three_segments() {
        let pm = make_pm_with_cwd("/home/foo/bar", "/home/foo/bar");
        // "/home/foo/bar" → ["/", "home", "/", "foo", "/", "bar"]
        // selectable: "/", "home", "foo", "bar" → 4 display segments
        assert_eq!(pm.cwd_display_segment_count(), 4);
    }

    #[test]
    fn test_cwd_path_for_index_no_cwd() {
        let pm = make_pm_with_cwd("", "");
        assert_eq!(pm.cwd_path_for_index(0), None);
    }

    #[test]
    fn test_cwd_path_for_index_zero() {
        let pm = make_pm_with_cwd("/home/foo/bar", "~/bar");
        // index 0 → current dir
        assert_eq!(pm.cwd_path_for_index(0), Some("/home/foo/bar".to_string()));
    }

    #[test]
    fn test_cwd_path_for_index_one() {
        let pm = make_pm_with_cwd("/home/foo/bar", "~/bar");
        // index 1 → parent dir
        assert_eq!(pm.cwd_path_for_index(1), Some("/home/foo".to_string()));
    }

    #[test]
    fn test_cwd_path_for_index_root() {
        let pm = make_pm_with_cwd("/home/foo/bar", "/home/foo/bar");
        // index 3 → root "/"
        assert_eq!(pm.cwd_path_for_index(3), Some("/".to_string()));
    }

    #[test]
    fn test_cwd_path_for_index_clamped_to_root() {
        let pm = make_pm_with_cwd("/home/foo/bar", "/home/foo/bar");
        // index beyond path depth → root
        assert_eq!(pm.cwd_path_for_index(100), Some("/".to_string()));
    }

    // --- WidgetMouseMode rendering -------------------------------------------

    #[test]
    fn test_format_prompt_line_widget_mouse_mode_enabled() {
        let segments = vec![PromptSegment::WidgetMouseMode {
            enabled_text: vec![TaggedSpan::new(Span::raw("mouse on"), Tag::Ps1Prompt)],
            disabled_text: vec![TaggedSpan::new(Span::raw("mouse off"), Tag::Ps1Prompt)],
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), true, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "mouse on");
    }

    #[test]
    fn test_format_prompt_line_widget_mouse_mode_disabled() {
        let segments = vec![PromptSegment::WidgetMouseMode {
            enabled_text: vec![TaggedSpan::new(Span::raw("mouse on"), Tag::Ps1Prompt)],
            disabled_text: vec![TaggedSpan::new(Span::raw("mouse off"), Tag::Ps1Prompt)],
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "mouse off");
    }

    #[test]
    fn test_expand_span_widget_mouse_mode_name_only() {
        let widget = PromptWidget::MouseMode {
            name: "MOUSE_WIDGET".to_string(),
            enabled_text: "on".to_string(),
            disabled_text: "off".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let span = Span::raw("MOUSE_WIDGET");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::WidgetMouseMode {
                enabled_text,
                disabled_text,
            } => {
                // In test builds expand_prompt_through_bash returns the text unchanged.
                assert_eq!(enabled_text[0].span.content, "on");
                assert_eq!(disabled_text[0].span.content, "off");
            }
            _ => panic!("expected WidgetMouseMode"),
        }
    }

    #[test]
    fn test_expand_span_widget_mouse_mode_surrounded_by_text() {
        let widget = PromptWidget::MouseMode {
            name: "MMODE".to_string(),
            enabled_text: "on".to_string(),
            disabled_text: "off".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let span = Span::raw("before MMODE after");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "before "),
            _ => panic!("expected Static at index 0"),
        }
        match &segs[1] {
            PromptSegment::WidgetMouseMode { .. } => {}
            _ => panic!("expected WidgetMouseMode at index 1"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content, " after"),
            _ => panic!("expected Static at index 2"),
        }
    }

    #[test]
    fn test_format_prompt_line_widget_copy_buffer() {
        let segments = vec![PromptSegment::WidgetCopyBuffer {
            text: vec![TaggedSpan::new(
                Span::raw("copy"),
                Tag::PromptCopyBufferWidget,
            )],
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        assert_eq!(line.spans[0].span.content, "copy");
        assert_eq!(
            line.spans[0].tag,
            crate::content_builder::SpanTag::Constant(Tag::PromptCopyBufferWidget)
        );
    }

    #[test]
    fn test_expand_span_widget_copy_buffer_name_only() {
        let widget = PromptWidget::CopyBuffer {
            name: "COPY_WIDGET".to_string(),
            text: "copy".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let segs = builder.expand_span_to_segments(Span::raw("COPY_WIDGET"));
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::WidgetCopyBuffer { text } => {
                assert_eq!(text[0].span.content, "copy");
                assert_eq!(
                    text[0].tag,
                    crate::content_builder::SpanTag::Constant(Tag::PromptCopyBufferWidget)
                );
            }
            _ => panic!("expected WidgetCopyBuffer"),
        }
    }

    #[test]
    fn test_expand_span_widget_copy_buffer_inherits_span_style() {
        // The copy-buffer widget output should inherit the surrounding prompt
        // span's style (matching how Animation and Cwd segments behave), with
        // the widget's own style winning when both are set.
        let widget = PromptWidget::CopyBuffer {
            name: "COPY_WIDGET".to_string(),
            text: "copy".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let base_style = Style::default().fg(Color::Blue);
        let segs =
            builder.expand_span_to_segments(Span::styled("COPY_WIDGET".to_string(), base_style));
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::WidgetCopyBuffer { text } => {
                assert!(!text.is_empty());
                for ts in text {
                    assert_eq!(ts.span.style.fg, Some(Color::Blue));
                }
            }
            _ => panic!("expected WidgetCopyBuffer"),
        }
    }

    // --- WidgetCustom rendering ---------------------------------------------

    #[test]
    fn test_format_prompt_line_widget_custom_pending() {
        // A pending custom widget should render its placeholder.
        // Spawn a long-running process so that try_wait returns None (still running).
        let child = std::process::Command::new("sleep")
            .arg("100")
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .expect("failed to spawn sleep for test");
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Pending {
                placeholder: vec![TaggedSpan::new(Span::raw("   "), Tag::Ps1Prompt)],
                child: KillOnDropChild::new(child),
                command: vec!["sleep".to_string(), "100".to_string()],
                prev_output_cell: Arc::new(Mutex::new(Vec::new())),
            },
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "   ");
        // Drop segs here; the Drop impl on WidgetCustomState will kill sleep.
    }

    #[test]
    fn test_format_prompt_line_widget_custom_done() {
        // A done custom widget renders the pre-tagged result spans.
        let result_spans = vec![TaggedSpan::new(Span::raw("output"), Tag::Ps1Prompt)];
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Done(result_spans),
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "output");
    }

    #[test]
    fn test_format_prompt_line_widget_custom_failed() {
        // A failed custom widget renders the "command failed (exit N)" error text.
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Failed(WidgetFailure {
                exit_code: Some(1),
                stdout: String::new(),
                stderr: String::new(),
            }),
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "command failed (exit 1)");
    }

    #[test]
    fn test_format_prompt_line_widget_custom_failed_no_exit_code() {
        // When there's no exit code (spawn failure), renders "command failed".
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Failed(WidgetFailure {
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
            }),
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "command failed");
    }

    #[test]
    fn test_expand_span_widget_custom_name() {
        // When a custom widget name appears in a span it should produce a
        // WidgetCustom segment. The spawn will fail immediately (ENOENT) since
        // the command doesn't exist, producing a Failed state, which still
        // satisfies the WidgetCustom(_) pattern match below.
        let widget = PromptWidget::Custom(PromptWidgetCustom {
            name: "MY_WIDGET".to_string(),
            command: vec!["nonexistent_test_command".to_string()],
            block: None,
            placeholder: crate::settings::Placeholder::Spaces(0),
            prev_output: Arc::new(Mutex::new(Vec::new())),
        });
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let span = Span::raw("before MY_WIDGET after");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "before "),
            _ => panic!("expected Static at 0"),
        }
        match &segs[1] {
            PromptSegment::WidgetCustom { .. } => {}
            _ => panic!("expected WidgetCustom at 1"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content, " after"),
            _ => panic!("expected Static at 2"),
        }
    }

    #[test]
    fn test_widget_custom_output_style_overrides_base_style() {
        // The output span's own style should override the base (prompt span)
        // style, not the other way round.
        let base_style = Style::default().fg(Color::Blue);
        let output_style = Style::default().fg(Color::Red);
        let result_spans = vec![TaggedSpan::new(
            Span::styled("output", output_style),
            Tag::Ps1Prompt,
        )];
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Done(result_spans),
            base_style,
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        assert_eq!(
            line.spans[0].span.style.fg,
            Some(Color::Red),
            "output fg should override base fg"
        );
    }

    #[test]
    fn test_widget_custom_base_style_fills_unset_fields() {
        // When the widget output leaves a style field unset, the base style
        // should fill it in.
        let base_style = Style::default().fg(Color::Green);
        let result_spans = vec![TaggedSpan::new(Span::raw("output"), Tag::Ps1Prompt)];
        let segs = vec![PromptSegment::WidgetCustom {
            state: WidgetCustomState::Done(result_spans),
            base_style,
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        assert_eq!(
            line.spans[0].span.style.fg,
            Some(Color::Green),
            "base fg should be used when output fg is unset"
        );
    }

    // --- WidgetLastCommandDuration rendering --------------------------------

    #[test]
    fn test_format_prompt_line_widget_last_command_duration_five_chars_no_close() {
        // When last_app_closed_at is None (first session), elapsed is 0 → " now ".
        let segs = vec![PromptSegment::WidgetLastCommandDuration {
            text: " now ".to_string(),
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, " now ");
    }

    #[test]
    fn test_format_prompt_line_widget_last_command_duration_five_chars_elapsed() {
        // Pre-computed " 1min" text is rendered as-is.
        let segs = vec![PromptSegment::WidgetLastCommandDuration {
            text: " 1min".to_string(),
            base_style: Style::default(),
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, " 1min");
    }

    #[test]
    fn test_format_prompt_line_widget_last_command_duration_inherits_base_style() {
        // The rendered span should carry the base_style set on the segment.
        let base_style = Style::default().fg(Color::Cyan);
        let segs = vec![PromptSegment::WidgetLastCommandDuration {
            text: " now ".to_string(),
            base_style,
        }];
        let line = format_prompt_line(&segs, &fixed_time(0), false, false);
        assert_eq!(line.spans[0].span.style.fg, Some(Color::Cyan));
    }

    #[test]
    fn test_expand_span_widget_last_command_duration_name() {
        // The widget name in a span should produce a WidgetLastCommandDuration segment
        // with a pre-computed text value ("0ns" when last_app_closed_at is None).
        let widget = PromptWidget::LastCommandDuration {
            name: "FLYLINE_LAST_COMMAND_DURATION".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let segs = builder.expand_span_to_segments(Span::raw("FLYLINE_LAST_COMMAND_DURATION"));
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::WidgetLastCommandDuration { text, .. } => {
                // No prior close → elapsed = 0 → "0ns".
                assert_eq!(text, "0ns");
            }
            _ => panic!("expected WidgetLastCommandDuration"),
        }
    }

    #[test]
    fn test_expand_span_widget_last_command_duration_surrounded_by_text() {
        let widget = PromptWidget::LastCommandDuration {
            name: "MY_DUR".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let segs = builder.expand_span_to_segments(Span::raw("time: MY_DUR done"));
        assert_eq!(segs.len(), 3);
        match &segs[0] {
            PromptSegment::Static(s) => assert_eq!(s.content, "time: "),
            _ => panic!("expected Static at 0"),
        }
        match &segs[1] {
            PromptSegment::WidgetLastCommandDuration { .. } => {}
            _ => panic!("expected WidgetLastCommandDuration at 1"),
        }
        match &segs[2] {
            PromptSegment::Static(s) => assert_eq!(s.content, " done"),
            _ => panic!("expected Static at 2"),
        }
    }

    // --- WidgetLeaderMode rendering -------------------------------------------

    #[test]
    fn test_format_prompt_line_widget_leader_mode_active() {
        let segments = vec![PromptSegment::WidgetLeaderMode {
            active_text: vec![TaggedSpan::new(Span::raw(" G "), Tag::Ps1Prompt)],
            inactive_text: vec![TaggedSpan::new(Span::raw(""), Tag::Ps1Prompt)],
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), false, true);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, " G ");
    }

    #[test]
    fn test_format_prompt_line_widget_leader_mode_inactive() {
        let segments = vec![PromptSegment::WidgetLeaderMode {
            active_text: vec![TaggedSpan::new(Span::raw(" G "), Tag::Ps1Prompt)],
            inactive_text: vec![TaggedSpan::new(Span::raw(""), Tag::Ps1Prompt)],
        }];
        let line = format_prompt_line(&segments, &fixed_time(0), false, false);
        let content: String = line.spans.iter().map(|s| s.span.content.as_ref()).collect();
        assert_eq!(content, "");
    }

    #[test]
    fn test_expand_span_widget_leader_mode() {
        let widget = PromptWidget::LeaderMode {
            name: "FLYLINE_LEADER_MODE".to_string(),
            active_text: "active".to_string(),
            inactive_text: "inactive".to_string(),
        };
        let widgets = [widget];
        let builder = PromptStringBuilder::new(vec![], &widgets);
        let span = Span::raw("FLYLINE_LEADER_MODE");
        let segs = builder.expand_span_to_segments(span);
        assert_eq!(segs.len(), 1);
        match &segs[0] {
            PromptSegment::WidgetLeaderMode {
                active_text,
                inactive_text,
            } => {
                assert_eq!(active_text[0].span.content, "active");
                assert_eq!(inactive_text[0].span.content, "inactive");
            }
            _ => panic!("expected WidgetLeaderMode"),
        }
    }
}
