use std::io::IsTerminal;
use std::sync::OnceLock;

use chrono::DateTime;
use lipgloss::Style;

use crate::core::time::{ms_to_iso, now_ms};

// ─── Color Mode ──────────────────────────────────────────────────────────────

/// Color mode derived from CLI `--color` flag and `NO_COLOR` env.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
    Auto,
    Always,
    Never,
}

// ─── Glyph Mode ─────────────────────────────────────────────────────────────

/// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphMode {
    Nerd,
    Unicode,
    Ascii,
}

impl GlyphMode {
    /// Detect glyph mode from CLI flag, env, or terminal heuristics.
    ///
    /// Precedence:
    /// 1. Explicit `--icons` CLI value (passed as `cli_flag`)
    /// 2. `LORE_ICONS` environment variable
    /// 3. Force ASCII fallback if `force_ascii` is true (robot mode)
    /// 4. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals
    ///    or `$NERD_FONTS=1`; otherwise Unicode
    pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self {
        // 1. CLI flag
        if let Some(flag) = cli_flag {
            return Self::from_str_lossy(flag);
        }

        // 2. Env var
        if let Ok(val) = std::env::var("LORE_ICONS") {
            return Self::from_str_lossy(&val);
        }

        // 3. Robot-safe fallback
        if force_ascii {
            return Self::Ascii;
        }

        // 4. Auto-detect
        if Self::detect_nerd_capable() {
            Self::Nerd
        } else {
            Self::Unicode
        }
    }

    fn from_str_lossy(s: &str) -> Self {
        match s.to_ascii_lowercase().as_str() {
            "nerd" => Self::Nerd,
            "unicode" => Self::Unicode,
            "ascii" => Self::Ascii,
            _ => Self::Unicode,
        }
    }

    fn detect_nerd_capable() -> bool {
        if std::env::var("NERD_FONTS")
            .ok()
            .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        {
            return true;
        }

        std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| {
            matches!(
                tp.as_str(),
                "WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app"
            )
        })
    }
}

// ─── Icons ──────────────────────────────────────────────────────────────────

/// Glyph catalog returning the right icon for the active `GlyphMode`.
pub struct Icons;

impl Icons {
    fn mode() -> GlyphMode {
        RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs)
    }

    // ── Status indicators ──

    pub fn success() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f058}",    // nf-fa-check_circle
            GlyphMode::Unicode => "\u{2714}", // heavy check mark
            GlyphMode::Ascii => "[ok]",
        }
    }

    pub fn warning() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f421}",    // nf-oct-alert
            GlyphMode::Unicode => "\u{26a0}", // warning sign
            GlyphMode::Ascii => "[!]",
        }
    }

    pub fn error() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f467}",    // nf-oct-x_circle
            GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
            GlyphMode::Ascii => "[X]",
        }
    }

    pub fn info() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f449}",    // nf-oct-info
            GlyphMode::Unicode => "\u{2139}", // information source
            GlyphMode::Ascii => "[i]",
        }
    }

    // ── Entity state ──

    pub fn issue_opened() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f41b}",    // nf-oct-issue_opened
            GlyphMode::Unicode => "\u{25cb}", // white circle
            GlyphMode::Ascii => "( )",
        }
    }

    pub fn issue_closed() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f41d}",    // nf-oct-issue_closed
            GlyphMode::Unicode => "\u{25cf}", // black circle
            GlyphMode::Ascii => "(x)",
        }
    }

    pub fn mr_opened() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f407}",    // nf-oct-git_pull_request
            GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards
            GlyphMode::Ascii => "<->",
        }
    }

    pub fn mr_merged() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f402}",    // nf-oct-git_merge
            GlyphMode::Unicode => "\u{2714}", // heavy check mark
            GlyphMode::Ascii => "[M]",
        }
    }

    pub fn mr_closed() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f430}",    // nf-oct-git_pull_request_closed
            GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
            GlyphMode::Ascii => "[X]",
        }
    }

    pub fn mr_draft() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f040}",    // nf-fa-pencil
            GlyphMode::Unicode => "\u{270e}", // lower right pencil
            GlyphMode::Ascii => "[D]",
        }
    }

    // ── Miscellaneous ──

    pub fn note() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f3ed}",    // nf-oct-comment
            GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle
            GlyphMode::Ascii => ">",
        }
    }

    pub fn search() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f422}",     // nf-oct-search
            GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass
            GlyphMode::Ascii => "?",
        }
    }

    pub fn user() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f415}", // nf-oct-person
            GlyphMode::Unicode => "@",
            GlyphMode::Ascii => "@",
        }
    }

    pub fn sync() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd => "\u{f46a}",    // nf-oct-sync
            GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow
            GlyphMode::Ascii => "<>",
        }
    }

    /// Waiting stage indicator (dimmed dot).
    pub fn waiting() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot
            GlyphMode::Ascii => ".",
        }
    }

    /// Progress bar characters: (filled, head, empty).
    pub fn progress_chars() -> &'static str {
        match Self::mode() {
            GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ",
            GlyphMode::Ascii => "=> ",
        }
    }
}

// ─── Renderer ───────────────────────────────────────────────────────────────

/// Global renderer singleton, initialized once in `main.rs`.
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();

pub struct LoreRenderer {
    /// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
    colors: bool,
    /// Icon tier for the session.
    glyphs: GlyphMode,
}

impl LoreRenderer {
    /// Initialize the global renderer. Call once at startup.
    pub fn init(mode: ColorMode, glyphs: GlyphMode) {
        let colors = match mode {
            ColorMode::Always => true,
            ColorMode::Never => false,
            ColorMode::Auto => {
                std::io::stdout().is_terminal()
                    && std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
            }
        };
        let _ = RENDERER.set(LoreRenderer { colors, glyphs });
    }

    /// Get the global renderer. Panics if `init` hasn't been called.
    pub fn get() -> &'static LoreRenderer {
        RENDERER
            .get()
            .expect("LoreRenderer::init must be called before get")
    }

    /// Try to get the global renderer. Returns `None` if `init` hasn't been called.
    pub fn try_get() -> Option<&'static LoreRenderer> {
        RENDERER.get()
    }

    /// Whether color output is enabled.
    pub fn colors_enabled(&self) -> bool {
        self.colors
    }

    /// The active glyph mode.
    pub fn glyph_mode(&self) -> GlyphMode {
        self.glyphs
    }
}

/// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been
/// initialized (e.g. in tests), which is the safe default.
fn colors_on() -> bool {
    RENDERER.get().is_some_and(LoreRenderer::colors_enabled)
}

// ─── Theme ───────────────────────────────────────────────────────────────────

/// Semantic style constants for the compact professional design language.
///
/// When colors are disabled (`--color never`, `NO_COLOR`, non-TTY), all methods
/// return a plain `Style::new()` that passes text through unchanged. This is
/// necessary because lipgloss's `Style::render()` uses a hardcoded TrueColor
/// renderer by default and does NOT auto-detect `NO_COLOR` or TTY state.
pub struct Theme;

impl Theme {
    // Text emphasis
    pub fn bold() -> Style {
        if colors_on() {
            Style::new().bold()
        } else {
            Style::new()
        }
    }

    pub fn dim() -> Style {
        if colors_on() {
            Style::new().faint()
        } else {
            Style::new()
        }
    }

    // Semantic colors
    pub fn success() -> Style {
        if colors_on() {
            Style::new().foreground("#10b981")
        } else {
            Style::new()
        }
    }

    pub fn warning() -> Style {
        if colors_on() {
            Style::new().foreground("#f59e0b")
        } else {
            Style::new()
        }
    }

    pub fn error() -> Style {
        if colors_on() {
            Style::new().foreground("#ef4444")
        } else {
            Style::new()
        }
    }

    pub fn info() -> Style {
        if colors_on() {
            Style::new().foreground("#06b6d4")
        } else {
            Style::new()
        }
    }

    pub fn accent() -> Style {
        if colors_on() {
            Style::new().foreground("#a855f7")
        } else {
            Style::new()
        }
    }

    // Entity styling
    pub fn issue_ref() -> Style {
        if colors_on() {
            Style::new().foreground("#06b6d4")
        } else {
            Style::new()
        }
    }

    pub fn mr_ref() -> Style {
        if colors_on() {
            Style::new().foreground("#a855f7")
        } else {
            Style::new()
        }
    }

    pub fn username() -> Style {
        if colors_on() {
            Style::new().foreground("#06b6d4")
        } else {
            Style::new()
        }
    }

    // State
    pub fn state_opened() -> Style {
        if colors_on() {
            Style::new().foreground("#10b981")
        } else {
            Style::new()
        }
    }

    pub fn state_closed() -> Style {
        if colors_on() {
            Style::new().faint()
        } else {
            Style::new()
        }
    }

    pub fn state_merged() -> Style {
        if colors_on() {
            Style::new().foreground("#a855f7")
        } else {
            Style::new()
        }
    }

    pub fn state_draft() -> Style {
        if colors_on() {
            Style::new().foreground("#6b7280")
        } else {
            Style::new()
        }
    }

    // Semantic additions
    pub fn muted() -> Style {
        if colors_on() {
            Style::new().foreground("#6b7280")
        } else {
            Style::new()
        }
    }

    pub fn highlight() -> Style {
        if colors_on() {
            Style::new().foreground("#fbbf24").bold()
        } else {
            Style::new()
        }
    }

    pub fn timing() -> Style {
        if colors_on() {
            Style::new().foreground("#94a3b8")
        } else {
            Style::new()
        }
    }

    // Structure
    pub fn section_title() -> Style {
        if colors_on() {
            Style::new().foreground("#06b6d4").bold()
        } else {
            Style::new()
        }
    }

    pub fn header() -> Style {
        if colors_on() {
            Style::new().bold()
        } else {
            Style::new()
        }
    }

    /// Apply semantic color to a stage-completion icon glyph.
    pub fn color_icon(icon: &str, has_errors: bool) -> String {
        if has_errors {
            Self::warning().render(icon)
        } else {
            Self::success().render(icon)
        }
    }
}

// ─── Shared Formatters ───────────────────────────────────────────────────────

/// Format an integer with thousands separators (commas).
pub fn format_number(n: i64) -> String {
    let (prefix, abs) = if n < 0 {
        ("-", n.unsigned_abs())
    } else {
        ("", n.unsigned_abs())
    };

    let s = abs.to_string();
    let chars: Vec<char> = s.chars().collect();
    let mut result = String::from(prefix);

    for (i, c) in chars.iter().enumerate() {
        if i > 0 && (chars.len() - i).is_multiple_of(3) {
            result.push(',');
        }
        result.push(*c);
    }

    result
}

/// Format an epoch-ms timestamp as a human-friendly relative time string.
pub fn format_relative_time(ms_epoch: i64) -> String {
    let now = now_ms();
    let diff = now - ms_epoch;

    if diff < 0 {
        return "in the future".to_string();
    }

    match diff {
        d if d < 60_000 => "just now".to_string(),
        d if d < 3_600_000 => format!("{} min ago", d / 60_000),
        d if d < 86_400_000 => {
            let n = d / 3_600_000;
            format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
        }
        d if d < 604_800_000 => {
            let n = d / 86_400_000;
            format!("{n} {} ago", if n == 1 { "day" } else { "days" })
        }
        d if d < 2_592_000_000 => {
            let n = d / 604_800_000;
            format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
        }
        _ => {
            let n = diff / 2_592_000_000;
            format!("{n} {} ago", if n == 1 { "month" } else { "months" })
        }
    }
}

/// Format an epoch-ms timestamp as YYYY-MM-DD.
pub fn format_date(ms: i64) -> String {
    let iso = ms_to_iso(ms);
    iso.split('T').next().unwrap_or(&iso).to_string()
}

/// Format an epoch-ms timestamp as YYYY-MM-DD HH:MM.
pub fn format_datetime(ms: i64) -> String {
    DateTime::from_timestamp_millis(ms)
        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
        .unwrap_or_else(|| "unknown".to_string())
}

/// Detect terminal width. Checks `COLUMNS` env, then stderr ioctl, falls back to 80.
pub fn terminal_width() -> usize {
    // 1. Explicit COLUMNS env (set by some shells, resized terminals)
    if let Ok(val) = std::env::var("COLUMNS")
        && let Ok(w) = val.parse::<usize>()
        && w > 0
    {
        return w;
    }

    // 2. ioctl on stderr (works even when stdout is piped)
    #[cfg(unix)]
    {
        use std::mem::MaybeUninit;
        #[allow(non_camel_case_types)]
        #[repr(C)]
        struct winsize {
            ws_row: libc::c_ushort,
            ws_col: libc::c_ushort,
            ws_xpixel: libc::c_ushort,
            ws_ypixel: libc::c_ushort,
        }
        let mut ws = MaybeUninit::<winsize>::uninit();
        // SAFETY: ioctl with TIOCGWINSZ writes into the winsize struct.
        // stderr (fd 2) is used because stdout may be piped.
        if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, ws.as_mut_ptr()) } == 0 {
            let ws = unsafe { ws.assume_init() };
            let w = ws.ws_col as usize;
            if w > 0 {
                return w;
            }
        }
    }

    80
}

/// Strip ANSI escape codes (SGR sequences) from a string.
pub fn strip_ansi(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // Consume `[`, then digits/semicolons, then the final letter
            if chars.next() == Some('[') {
                for c in chars.by_ref() {
                    if c.is_ascii_alphabetic() {
                        break;
                    }
                }
            }
        } else {
            out.push(c);
        }
    }
    out
}

/// Compute the visible width of a string that may contain ANSI escape sequences.
pub fn visible_width(s: &str) -> usize {
    strip_ansi(s).chars().count()
}

/// Truncate a string to `max` characters, appending "..." if truncated.
pub fn truncate(s: &str, max: usize) -> String {
    if max < 4 {
        return s.chars().take(max).collect();
    }
    if s.chars().count() <= max {
        s.to_owned()
    } else {
        let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
        format!("{truncated}...")
    }
}

/// Truncate and right-pad to exactly `width` visible characters.
pub fn truncate_pad(s: &str, width: usize) -> String {
    let t = truncate(s, width);
    let count = t.chars().count();
    if count < width {
        format!("{t}{}", " ".repeat(width - count))
    } else {
        t
    }
}

/// Compute available width for a flexible field given overhead chars.
/// Returns `terminal_width - overhead`, clamped to `[min, terminal_width]`.
pub fn flex_width(overhead: usize, min: usize) -> usize {
    terminal_width().saturating_sub(overhead).max(min)
}

/// Word-wrap text to `width`, prepending `indent` to continuation lines.
/// Returns a single string with embedded newlines.
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
    let mut result = String::new();
    let mut current_line = String::new();

    for word in text.split_whitespace() {
        if current_line.is_empty() {
            current_line = word.to_string();
        } else if current_line.len() + 1 + word.len() <= width {
            current_line.push(' ');
            current_line.push_str(word);
        } else {
            if !result.is_empty() {
                result.push('\n');
                result.push_str(indent);
            }
            result.push_str(&current_line);
            current_line = word.to_string();
        }
    }

    if !current_line.is_empty() {
        if !result.is_empty() {
            result.push('\n');
            result.push_str(indent);
        }
        result.push_str(&current_line);
    }

    result
}

/// Word-wrap text to `width`, returning a Vec of lines.
pub fn wrap_lines(text: &str, width: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut current = String::new();

    for word in text.split_whitespace() {
        if current.is_empty() {
            current = word.to_string();
        } else if current.len() + 1 + word.len() <= width {
            current.push(' ');
            current.push_str(word);
        } else {
            lines.push(current);
            current = word.to_string();
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }

    lines
}

/// Render a section divider: `── Title ──────────────────────`
pub fn section_divider(title: &str) -> String {
    // prefix: 2 indent + 2 box-drawing + 1 space = 5
    // suffix: 1 space + trailing box-drawing
    let used = 5 + title.len() + 1;
    let rule_len = terminal_width().saturating_sub(used);
    format!(
        "\n  {} {} {}",
        Theme::dim().render("\u{2500}\u{2500}"),
        Theme::section_title().render(title),
        Theme::dim().render(&"\u{2500}".repeat(rule_len)),
    )
}

/// Apply a hex color (e.g. `"#ff6b6b"`) to text via lipgloss.
/// Returns styled text if hex is valid, or plain text otherwise.
pub fn style_with_hex(text: &str, hex: Option<&str>) -> String {
    match hex {
        Some(h) if h.len() >= 4 && colors_on() => Style::new().foreground(h).render(text),
        _ => text.to_string(),
    }
}

/// Format a slice of labels with overflow indicator.
/// e.g. `["bug", "urgent"]` with max 2 -> `"[bug, urgent]"`
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"[a, b +2]"`
pub fn format_labels(labels: &[String], max_shown: usize) -> String {
    if labels.is_empty() {
        return String::new();
    }

    let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
    let overflow = labels.len().saturating_sub(max_shown);

    if overflow > 0 {
        format!("[{} +{}]", shown.join(", "), overflow)
    } else {
        format!("[{}]", shown.join(", "))
    }
}

/// Format a slice of labels without brackets.
/// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"`
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"`
pub fn format_labels_bare(labels: &[String], max_shown: usize) -> String {
    if labels.is_empty() {
        return String::new();
    }

    let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
    let overflow = labels.len().saturating_sub(max_shown);

    if overflow > 0 {
        format!("{} +{}", shown.join(", "), overflow)
    } else {
        shown.join(", ")
    }
}

/// Format a duration in milliseconds as a human-friendly string.
pub fn format_duration_ms(ms: u64) -> String {
    if ms < 1000 {
        format!("{ms}ms")
    } else {
        format!("{:.1}s", ms as f64 / 1000.0)
    }
}

/// Format an epoch-ms timestamp as a compact relative time string.
/// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns.
pub fn format_relative_time_compact(ms_epoch: i64) -> String {
    let now = now_ms();
    let diff = now - ms_epoch;

    if diff < 0 {
        return "future".to_string();
    }

    match diff {
        d if d < 60_000 => "now".to_string(),
        d if d < 3_600_000 => format!("{}m", d / 60_000),
        d if d < 86_400_000 => format!("{}h", d / 3_600_000),
        d if d < 604_800_000 => format!("{}d", d / 86_400_000),
        d if d < 2_592_000_000 => format!("{}w", d / 604_800_000),
        _ => format!("{}mo", diff / 2_592_000_000),
    }
}

// ─── Table Renderer ──────────────────────────────────────────────────────────

/// Column alignment for the table renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
    Left,
    Right,
}

/// A cell in a table row, optionally styled.
pub struct StyledCell {
    pub text: String,
    pub style: Option<Style>,
}

impl StyledCell {
    /// Create a plain (unstyled) cell.
    pub fn plain(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            style: None,
        }
    }

    /// Create a styled cell.
    pub fn styled(text: impl Into<String>, style: Style) -> Self {
        Self {
            text: text.into(),
            style: Some(style),
        }
    }

    /// The visible width of the cell text (ANSI-unaware character count).
    fn visible_width(&self) -> usize {
        // Use char count since our text doesn't contain ANSI before rendering
        self.text.chars().count()
    }
}

/// A compact table renderer built on lipgloss.
///
/// Design: no borders, bold headers, thin `─` separator, 2-space column gaps.
#[derive(Default)]
pub struct Table {
    headers: Vec<String>,
    rows: Vec<Vec<StyledCell>>,
    alignments: Vec<Align>,
    max_widths: Vec<Option<usize>>,
    flex_col_idx: Option<usize>,
    col_count: usize,
    indent: usize,
}

impl Table {
    pub fn new() -> Self {
        Self::default()
    }

    /// Set column headers.
    pub fn headers(mut self, h: &[&str]) -> Self {
        self.headers = h.iter().map(|s| (*s).to_string()).collect();
        self.col_count = self.headers.len();
        self.alignments.resize(self.col_count, Align::Left);
        self.max_widths.resize(self.col_count, None);
        self
    }

    /// Set column count without headers (headerless table).
    pub fn columns(mut self, n: usize) -> Self {
        self.col_count = n;
        self.alignments.resize(n, Align::Left);
        self.max_widths.resize(n, None);
        self
    }

    /// Set indent (number of spaces) prepended to each row.
    pub fn indent(mut self, spaces: usize) -> Self {
        self.indent = spaces;
        self
    }

    /// Add a data row.
    pub fn add_row(&mut self, cells: Vec<StyledCell>) {
        self.rows.push(cells);
    }

    /// Set alignment for a column.
    pub fn align(mut self, col: usize, a: Align) -> Self {
        if col < self.alignments.len() {
            self.alignments[col] = a;
        }
        self
    }

    /// Set maximum width for a column (content will be truncated).
    pub fn max_width(mut self, col: usize, w: usize) -> Self {
        if col < self.max_widths.len() {
            self.max_widths[col] = Some(w);
        }
        self
    }

    /// Designate one column as flexible: it absorbs remaining terminal width
    /// after fixed-width columns are sized. Replaces both `max_width()` constraints
    /// and pre-truncation for that column.
    pub fn flex_col(mut self, col: usize) -> Self {
        self.flex_col_idx = Some(col);
        self
    }

    /// Render the table to a string.
    pub fn render(&self) -> String {
        let col_count = self.col_count;
        if col_count == 0 {
            return String::new();
        }

        let gap = "  "; // 2-space gap between columns
        let indent_str = " ".repeat(self.indent);

        // Compute column widths from headers (if any) and all row cells
        let mut widths: Vec<usize> = if self.headers.is_empty() {
            vec![0; col_count]
        } else {
            self.headers.iter().map(|h| h.chars().count()).collect()
        };

        for row in &self.rows {
            for (i, cell) in row.iter().enumerate() {
                if i < col_count {
                    widths[i] = widths[i].max(cell.visible_width());
                }
            }
        }

        // Apply max_width constraints (for non-flex columns)
        for (i, max_w) in self.max_widths.iter().enumerate() {
            if let Some(max) = max_w
                && i < widths.len()
                && self.flex_col_idx != Some(i)
            {
                widths[i] = widths[i].min(*max);
            }
        }

        // Apply flex_col: the flex column absorbs remaining terminal width
        if let Some(fc) = self.flex_col_idx
            && fc < widths.len()
        {
            let gap_total = gap.len() * col_count.saturating_sub(1);
            let fixed_sum: usize = widths
                .iter()
                .enumerate()
                .filter(|(i, _)| *i != fc)
                .map(|(_, w)| w)
                .sum::<usize>()
                + gap_total
                + self.indent;
            let available = terminal_width().saturating_sub(fixed_sum);
            let natural = widths[fc];
            let min_flex = 20.min(natural);
            widths[fc] = available.clamp(min_flex, natural);
        }

        let mut out = String::new();

        // Header row + separator (only when headers are set)
        if !self.headers.is_empty() {
            let header_parts: Vec<String> = self
                .headers
                .iter()
                .enumerate()
                .map(|(i, h)| {
                    let w = widths.get(i).copied().unwrap_or(0);
                    let text = truncate(h, w);
                    pad_cell(
                        &text,
                        w,
                        self.alignments.get(i).copied().unwrap_or(Align::Left),
                    )
                })
                .collect();
            out.push_str(&indent_str);
            out.push_str(&Theme::header().render(&header_parts.join(gap)));
            out.push('\n');

            let total_width: usize =
                widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
            out.push_str(&indent_str);
            out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
            out.push('\n');
        }

        // Data rows
        for row in &self.rows {
            let mut parts: Vec<String> = Vec::with_capacity(col_count);
            for i in 0..col_count {
                let w = widths.get(i).copied().unwrap_or(0);
                let align = self.alignments.get(i).copied().unwrap_or(Align::Left);

                if let Some(cell) = row.get(i) {
                    // Truncate the raw text first, then style it
                    let truncated = truncate(&cell.text, w);
                    let styled = match &cell.style {
                        Some(s) => s.render(&truncated),
                        None => truncated.clone(),
                    };
                    // Pad based on visible (unstyled) width
                    let visible_w = truncated.chars().count();
                    let padding = w.saturating_sub(visible_w);
                    match align {
                        Align::Left => {
                            parts.push(format!("{}{}", styled, " ".repeat(padding)));
                        }
                        Align::Right => {
                            parts.push(format!("{}{}", " ".repeat(padding), styled));
                        }
                    }
                } else {
                    // Missing cell - empty padding
                    parts.push(" ".repeat(w));
                }
            }
            out.push_str(&indent_str);
            out.push_str(&parts.join(gap));
            out.push('\n');
        }

        out
    }
}

/// Pad a plain string to a target width with the given alignment.
fn pad_cell(text: &str, width: usize, align: Align) -> String {
    let visible = text.chars().count();
    let padding = width.saturating_sub(visible);
    match align {
        Align::Left => format!("{}{}", text, " ".repeat(padding)),
        Align::Right => format!("{}{}", " ".repeat(padding), text),
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── format_number ──

    #[test]
    fn format_number_small_values() {
        assert_eq!(format_number(0), "0");
        assert_eq!(format_number(1), "1");
        assert_eq!(format_number(100), "100");
        assert_eq!(format_number(999), "999");
    }

    #[test]
    fn format_number_thousands_separators() {
        assert_eq!(format_number(1000), "1,000");
        assert_eq!(format_number(12345), "12,345");
        assert_eq!(format_number(1_234_567), "1,234,567");
    }

    #[test]
    fn format_number_negative() {
        assert_eq!(format_number(-1), "-1");
        assert_eq!(format_number(-1000), "-1,000");
        assert_eq!(format_number(-1_234_567), "-1,234,567");
    }

    // ── format_date ──

    #[test]
    fn format_date_extracts_date_part() {
        // 2024-01-15T00:00:00Z in ms
        let ms = 1_705_276_800_000;
        let date = format_date(ms);
        assert!(date.starts_with("2024-01-15"), "got: {date}");
    }

    #[test]
    fn format_date_epoch_zero() {
        assert_eq!(format_date(0), "1970-01-01");
    }

    // ── format_datetime ──

    #[test]
    fn format_datetime_basic() {
        let ms = 1_705_312_800_000; // 2024-01-15T10:00:00Z
        let dt = format_datetime(ms);
        assert!(dt.starts_with("2024-01-15"), "got: {dt}");
        assert!(dt.contains("10:00"), "got: {dt}");
    }

    // ── truncate ──

    #[test]
    fn truncate_within_limit() {
        assert_eq!(truncate("hello", 10), "hello");
        assert_eq!(truncate("hello", 5), "hello");
    }

    #[test]
    fn truncate_over_limit() {
        assert_eq!(truncate("hello world", 8), "hello...");
        assert_eq!(truncate("abcdefghij", 7), "abcd...");
    }

    #[test]
    fn truncate_very_short_max() {
        assert_eq!(truncate("hello", 3), "hel");
        assert_eq!(truncate("hello", 1), "h");
    }

    #[test]
    fn truncate_unicode() {
        // Multi-byte chars should not panic
        let s = "caf\u{e9} latt\u{e9}";
        let result = truncate(s, 6);
        assert!(result.chars().count() <= 6, "got: {result}");
    }

    // ── wrap_indent ──

    #[test]
    fn wrap_indent_single_line() {
        assert_eq!(wrap_indent("hello world", 80, "  "), "hello world");
    }

    #[test]
    fn wrap_indent_wraps_long_text() {
        let result = wrap_indent("one two three four five", 10, "  ");
        assert!(result.contains('\n'), "expected wrapping, got: {result}");
        // Continuation lines should have indent
        let lines: Vec<&str> = result.lines().collect();
        assert!(lines.len() >= 2);
        assert!(
            lines[1].starts_with("  "),
            "expected indent on line 2: {:?}",
            lines[1]
        );
    }

    #[test]
    fn wrap_indent_empty() {
        assert_eq!(wrap_indent("", 80, "  "), "");
    }

    // ── wrap_lines ──

    #[test]
    fn wrap_lines_single_line() {
        assert_eq!(wrap_lines("hello world", 80), vec!["hello world"]);
    }

    #[test]
    fn wrap_lines_wraps() {
        let lines = wrap_lines("one two three four five", 10);
        assert!(lines.len() >= 2, "expected wrapping, got: {lines:?}");
    }

    #[test]
    fn wrap_lines_empty() {
        let lines = wrap_lines("", 80);
        assert!(lines.is_empty());
    }

    // ── section_divider ──

    #[test]
    fn section_divider_contains_title() {
        let result = section_divider("Documents");
        // Strip ANSI to check content
        let plain = strip_ansi(&result);
        assert!(plain.contains("Documents"), "got: {plain}");
        assert!(
            plain.contains("\u{2500}"),
            "expected box-drawing chars in: {plain}"
        );
    }

    // ── style_with_hex ──

    #[test]
    fn style_with_hex_none_returns_plain() {
        assert_eq!(style_with_hex("hello", None), "hello");
    }

    #[test]
    fn style_with_hex_invalid_returns_plain() {
        assert_eq!(style_with_hex("hello", Some("")), "hello");
        assert_eq!(style_with_hex("hello", Some("x")), "hello");
    }

    #[test]
    fn style_with_hex_valid_contains_text() {
        let result = style_with_hex("hello", Some("#ff0000"));
        assert!(
            result.contains("hello"),
            "styled text should contain original: {result}"
        );
    }

    // ── format_labels ──

    #[test]
    fn format_labels_empty() {
        assert_eq!(format_labels(&[], 2), "");
    }

    #[test]
    fn format_labels_single() {
        assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
    }

    #[test]
    fn format_labels_multiple() {
        let labels = vec!["bug".to_string(), "urgent".to_string()];
        assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
    }

    #[test]
    fn format_labels_overflow() {
        let labels = vec![
            "bug".to_string(),
            "urgent".to_string(),
            "wip".to_string(),
            "blocked".to_string(),
        ];
        assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
    }

    // ── format_duration_ms ──

    #[test]
    fn format_duration_ms_sub_second() {
        assert_eq!(format_duration_ms(42), "42ms");
        assert_eq!(format_duration_ms(999), "999ms");
    }

    #[test]
    fn format_duration_ms_seconds() {
        assert_eq!(format_duration_ms(1000), "1.0s");
        assert_eq!(format_duration_ms(5200), "5.2s");
        assert_eq!(format_duration_ms(12500), "12.5s");
    }

    // ── format_relative_time ──
    // Note: these are harder to test deterministically since they depend on now_ms().
    // We test the boundary behavior by checking that the function doesn't panic
    // and returns reasonable-looking strings.

    #[test]
    fn format_relative_time_future() {
        let future = now_ms() + 60_000;
        assert_eq!(format_relative_time(future), "in the future");
    }

    #[test]
    fn format_relative_time_just_now() {
        let recent = now_ms() - 5_000;
        assert_eq!(format_relative_time(recent), "just now");
    }

    #[test]
    fn format_relative_time_minutes() {
        let mins_ago = now_ms() - 300_000; // 5 minutes
        let result = format_relative_time(mins_ago);
        assert!(result.contains("min ago"), "got: {result}");
    }

    #[test]
    fn format_relative_time_hours() {
        let hours_ago = now_ms() - 7_200_000; // 2 hours
        let result = format_relative_time(hours_ago);
        assert!(result.contains("hours ago"), "got: {result}");
    }

    #[test]
    fn format_relative_time_days() {
        let days_ago = now_ms() - 172_800_000; // 2 days
        let result = format_relative_time(days_ago);
        assert!(result.contains("days ago"), "got: {result}");
    }

    // ── helpers ──

    // ── Table ──

    #[test]
    fn table_empty_headers_returns_empty() {
        let table = Table::new();
        assert_eq!(table.render(), "");
    }

    #[test]
    fn table_headers_only() {
        let table = Table::new().headers(&["ID", "Name", "Status"]);
        let result = table.render();
        let plain = strip_ansi(&result);
        assert!(plain.contains("ID"), "got: {plain}");
        assert!(plain.contains("Name"), "got: {plain}");
        assert!(plain.contains("Status"), "got: {plain}");
        assert!(plain.contains("\u{2500}"), "expected separator in: {plain}");
        // Should have header line + separator line
        let lines: Vec<&str> = result.lines().collect();
        assert_eq!(
            lines.len(),
            2,
            "expected 2 lines (header + separator), got {}",
            lines.len()
        );
    }

    #[test]
    fn table_single_row() {
        let mut table = Table::new().headers(&["ID", "Title", "State"]);
        table.add_row(vec![
            StyledCell::plain("1"),
            StyledCell::plain("Fix bug"),
            StyledCell::plain("open"),
        ]);
        let result = table.render();
        let plain = strip_ansi(&result);
        // 3 lines: header, separator, data
        let lines: Vec<&str> = plain.lines().collect();
        assert_eq!(
            lines.len(),
            3,
            "expected 3 lines, got {}: {plain}",
            lines.len()
        );
        assert!(lines[2].contains("Fix bug"), "data row missing: {plain}");
    }

    #[test]
    fn table_multi_row() {
        let mut table = Table::new().headers(&["ID", "Name"]);
        table.add_row(vec![StyledCell::plain("1"), StyledCell::plain("Alice")]);
        table.add_row(vec![StyledCell::plain("2"), StyledCell::plain("Bob")]);
        table.add_row(vec![StyledCell::plain("3"), StyledCell::plain("Charlie")]);
        let result = table.render();
        let plain = strip_ansi(&result);
        let lines: Vec<&str> = plain.lines().collect();
        assert_eq!(
            lines.len(),
            5,
            "expected 5 lines (1 header + 1 sep + 3 data): {plain}"
        );
    }

    #[test]
    fn table_right_align() {
        let mut table = Table::new()
            .headers(&["Name", "Score"])
            .align(1, Align::Right);
        table.add_row(vec![StyledCell::plain("Alice"), StyledCell::plain("95")]);
        table.add_row(vec![StyledCell::plain("Bob"), StyledCell::plain("8")]);
        let result = table.render();
        let plain = strip_ansi(&result);
        // The "8" should be right-aligned (padded with spaces on the left)
        let data_lines: Vec<&str> = plain.lines().skip(2).collect();
        let score_line = data_lines[1]; // Bob's line
        // Score column should have leading space before "8"
        assert!(
            score_line.contains("  8") || score_line.ends_with(" 8"),
            "expected right-aligned '8': {score_line}"
        );
    }

    #[test]
    fn table_max_width_truncates() {
        let mut table = Table::new().headers(&["ID", "Title"]).max_width(1, 10);
        table.add_row(vec![
            StyledCell::plain("1"),
            StyledCell::plain("This is a very long title that should be truncated"),
        ]);
        let result = table.render();
        let plain = strip_ansi(&result);
        // The title should be truncated to 10 chars (7 + "...")
        assert!(plain.contains("..."), "expected truncation in: {plain}");
        // The full title should NOT appear
        assert!(
            !plain.contains("should be truncated"),
            "title not truncated: {plain}"
        );
    }

    #[test]
    fn table_styled_cells_dont_break_alignment() {
        let mut table = Table::new().headers(&["Name", "Status"]);
        table.add_row(vec![
            StyledCell::plain("Alice"),
            StyledCell::styled("open", Theme::success()),
        ]);
        table.add_row(vec![
            StyledCell::plain("Bob"),
            StyledCell::styled("closed", Theme::dim()),
        ]);
        let result = table.render();
        let plain = strip_ansi(&result);
        let lines: Vec<&str> = plain.lines().collect();
        assert_eq!(lines.len(), 4); // header + sep + 2 rows
        // Both data rows should exist
        assert!(plain.contains("Alice"), "missing Alice: {plain}");
        assert!(plain.contains("Bob"), "missing Bob: {plain}");
    }

    #[test]
    fn table_flex_col_absorbs_remaining_width() {
        // Simulate a narrow terminal by using COLUMNS env
        let saved = std::env::var("COLUMNS").ok();
        unsafe { std::env::set_var("COLUMNS", "60") };

        let mut table = Table::new().headers(&["ID", "Title", "State"]).flex_col(1);
        table.add_row(vec![
            StyledCell::plain("1"),
            StyledCell::plain(
                "A very long title that would normally extend beyond the terminal width",
            ),
            StyledCell::plain("open"),
        ]);
        let result = table.render();
        let plain = strip_ansi(&result);

        // The title should be truncated (flex col constrains it)
        assert!(
            plain.contains("..."),
            "expected flex col truncation in: {plain}"
        );
        // The full title should NOT appear
        assert!(
            !plain.contains("beyond the terminal width"),
            "flex col should have truncated title: {plain}"
        );

        // Restore
        match saved {
            Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
            None => unsafe { std::env::remove_var("COLUMNS") },
        }
    }

    #[test]
    fn table_flex_col_does_not_truncate_when_fits() {
        let saved = std::env::var("COLUMNS").ok();
        unsafe { std::env::set_var("COLUMNS", "120") };

        let mut table = Table::new().headers(&["ID", "Title", "State"]).flex_col(1);
        table.add_row(vec![
            StyledCell::plain("1"),
            StyledCell::plain("Short title"),
            StyledCell::plain("open"),
        ]);
        let result = table.render();
        let plain = strip_ansi(&result);

        // Short title should appear in full
        assert!(
            plain.contains("Short title"),
            "short title should not be truncated: {plain}"
        );

        match saved {
            Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
            None => unsafe { std::env::remove_var("COLUMNS") },
        }
    }

    #[test]
    fn table_missing_cells_padded() {
        let mut table = Table::new().headers(&["A", "B", "C"]);
        // Row with fewer cells than columns
        table.add_row(vec![StyledCell::plain("1")]);
        let result = table.render();
        // Should not panic
        let plain = strip_ansi(&result);
        assert!(plain.contains("1"), "got: {plain}");
    }

    #[test]
    fn flex_width_respects_min() {
        // flex_width = max(terminal_width() - overhead, min)
        // With very large overhead, result should be at least `min`
        let result = flex_width(10_000, 25);
        assert_eq!(
            result, 25,
            "should clamp to min when overhead exceeds width"
        );

        // With zero overhead, result should be terminal_width()
        let result = flex_width(0, 1);
        assert!(result >= 1, "should be at least min");

        // Monotonicity: more overhead → smaller or equal result
        let a = flex_width(10, 20);
        let b = flex_width(50, 20);
        assert!(
            a >= b,
            "more overhead should yield smaller width: {a} vs {b}"
        );
    }

    // ── GlyphMode ──

    #[test]
    fn glyph_mode_cli_flag_overrides_all() {
        assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii);
        assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd);
        assert_eq!(
            GlyphMode::detect(Some("unicode"), false),
            GlyphMode::Unicode
        );
    }

    #[test]
    fn glyph_mode_force_ascii_is_fallback_when_no_explicit_icon_mode() {
        // Clear env var so it doesn't short-circuit the force_ascii path.
        // SAFETY: tests run single-threaded per process for env-var-dependent tests.
        let saved = std::env::var("LORE_ICONS").ok();
        unsafe { std::env::remove_var("LORE_ICONS") };
        let result = GlyphMode::detect(None, true);
        if let Some(v) = saved {
            unsafe { std::env::set_var("LORE_ICONS", v) };
        }
        assert_eq!(result, GlyphMode::Ascii);
    }

    #[test]
    fn glyph_mode_force_ascii_does_not_override_cli_flag() {
        assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Nerd);
        assert_eq!(GlyphMode::detect(Some("unicode"), true), GlyphMode::Unicode);
    }

    #[test]
    fn glyph_mode_unknown_falls_back_to_unicode() {
        assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode);
    }

    // ── Icons ──

    #[test]
    fn icons_return_nonempty_strings() {
        // Without RENDERER initialized, Icons falls back to Unicode mode
        assert!(!Icons::success().is_empty());
        assert!(!Icons::warning().is_empty());
        assert!(!Icons::error().is_empty());
        assert!(!Icons::info().is_empty());
        assert!(!Icons::issue_opened().is_empty());
        assert!(!Icons::issue_closed().is_empty());
        assert!(!Icons::mr_opened().is_empty());
        assert!(!Icons::mr_merged().is_empty());
        assert!(!Icons::mr_closed().is_empty());
        assert!(!Icons::mr_draft().is_empty());
        assert!(!Icons::note().is_empty());
        assert!(!Icons::search().is_empty());
        assert!(!Icons::user().is_empty());
        assert!(!Icons::sync().is_empty());
        assert!(!Icons::waiting().is_empty());
        assert!(!Icons::progress_chars().is_empty());
    }

    // ── format_labels_bare ──

    #[test]
    fn format_labels_bare_empty() {
        assert_eq!(format_labels_bare(&[], 2), "");
    }

    #[test]
    fn format_labels_bare_no_brackets() {
        let labels = vec!["bug".to_string(), "urgent".to_string()];
        assert_eq!(format_labels_bare(&labels, 2), "bug, urgent");
    }

    #[test]
    fn format_labels_bare_overflow() {
        let labels = vec![
            "a".to_string(),
            "b".to_string(),
            "c".to_string(),
            "d".to_string(),
        ];
        assert_eq!(format_labels_bare(&labels, 2), "a, b +2");
    }

    // ── format_relative_time_compact ──

    #[test]
    fn format_relative_time_compact_now() {
        let recent = now_ms() - 5_000;
        assert_eq!(format_relative_time_compact(recent), "now");
    }

    #[test]
    fn format_relative_time_compact_minutes() {
        let mins_ago = now_ms() - 300_000; // 5 minutes
        assert_eq!(format_relative_time_compact(mins_ago), "5m");
    }

    #[test]
    fn format_relative_time_compact_hours() {
        let hours_ago = now_ms() - 7_200_000; // 2 hours
        assert_eq!(format_relative_time_compact(hours_ago), "2h");
    }

    #[test]
    fn format_relative_time_compact_days() {
        let days_ago = now_ms() - 172_800_000; // 2 days
        assert_eq!(format_relative_time_compact(days_ago), "2d");
    }

    #[test]
    fn format_relative_time_compact_weeks() {
        let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks
        assert_eq!(format_relative_time_compact(weeks_ago), "2w");
    }

    #[test]
    fn format_relative_time_compact_months() {
        let months_ago = now_ms() - 5_184_000_000; // ~2 months
        assert_eq!(format_relative_time_compact(months_ago), "2mo");
    }

    #[test]
    fn format_relative_time_compact_future() {
        let future = now_ms() + 60_000;
        assert_eq!(format_relative_time_compact(future), "future");
    }

    // ── helpers ──

    /// Delegate to the public `strip_ansi` for test assertions.
    fn strip_ansi(s: &str) -> String {
        super::strip_ansi(s)
    }

    #[test]
    fn visible_width_strips_ansi() {
        let styled = "\x1b[1mhello\x1b[0m".to_string();
        assert_eq!(super::visible_width(&styled), 5);
    }

    #[test]
    fn visible_width_plain_string() {
        assert_eq!(super::visible_width("hello"), 5);
    }
}
