//! Responsive Ratatui rendering for the application shell.

use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Wrap},
};
use zeroize::Zeroizing;

use crate::{
    action::{context_actions, help_actions},
    app::{App, Mode, PaneFocus},
    editor::EntryEditor,
    viewer::EntryViewer,
};

const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5;
const ACCENT_COLOR: Color = Color::Cyan;
const SELECTED_FOREGROUND: Color = Color::Black;
const SELECTED_BACKGROUND: Color = Color::White;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorCapability {
    Color,
    Monochrome,
}

impl ColorCapability {
    pub fn detect() -> Self {
        Self::from_environment(
            std::env::var_os("TERM").as_deref(),
            std::env::var_os("NO_COLOR").is_some(),
        )
    }

    fn from_environment(term: Option<&std::ffi::OsStr>, no_color: bool) -> Self {
        if no_color || term.is_some_and(|term| term == "dumb") {
            Self::Monochrome
        } else {
            Self::Color
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LayoutClass {
    TooSmall,
    Narrow,
    Normal,
    Wide,
}

pub fn layout_class(area: Rect) -> LayoutClass {
    if area.width < MINIMUM_WIDTH || area.height < MINIMUM_HEIGHT {
        LayoutClass::TooSmall
    } else if area.width < 80 {
        LayoutClass::Narrow
    } else if area.width < 120 {
        LayoutClass::Normal
    } else {
        LayoutClass::Wide
    }
}

pub fn draw(frame: &mut Frame, app: &App) {
    draw_with_color_capability(frame, app, ColorCapability::Color);
}

pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: ColorCapability) {
    draw_inner(frame, app, capability);
    if capability == ColorCapability::Monochrome {
        for cell in &mut frame.buffer_mut().content {
            if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND {
                cell.modifier.insert(Modifier::REVERSED);
            }
            cell.set_fg(Color::Reset).set_bg(Color::Reset);
        }
    }
}

fn draw_inner(frame: &mut Frame, app: &App, capability: ColorCapability) {
    let area = frame.area();
    if layout_class(area) == LayoutClass::TooSmall {
        frame.render_widget(
            Paragraph::new(format!(
                "Terminal too small ({}×{}). Need at least {MINIMUM_WIDTH}×{MINIMUM_HEIGHT}.",
                area.width, area.height
            ))
            .block(Block::bordered().title(ironstorage::PRODUCT_NAME))
            .wrap(Wrap { trim: true }),
            area,
        );
        return;
    }

    let rows = Layout::vertical([
        Constraint::Min(3),
        Constraint::Length(1),
        Constraint::Length(1),
        Constraint::Length(1),
    ])
    .split(area);
    render_content(frame, app, rows[0], capability);
    frame.render_widget(status_line(app), rows[1]);
    frame.render_widget(context_line(app), rows[2]);
    frame.render_widget(prompt_line(app), rows[3]);
}

fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCapability) {
    if app.mode() == Mode::Locked {
        frame.render_widget(
            Paragraph::new("The password store is locked. Authentication is required.")
                .block(pane_block("Locked", true, capability))
                .wrap(Wrap { trim: true }),
            area,
        );
        return;
    }

    if let Some(popup) = app.qr_popup() {
        let matrix = popup.matrix();
        let padded_width = matrix.width() + 8;
        let needed_width =
            u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX);
        let needed_height = u16::try_from(
            padded_width
                .next_multiple_of(2)
                .saturating_div(2)
                .saturating_add(2),
        )
        .unwrap_or(u16::MAX);
        let text = if area.width < needed_width || area.height < needed_height {
            format!(
                "Terminal too small for OTP QR (need {needed_width}×{needed_height}); resize or Esc to close."
            )
        } else {
            let rendered = matrix.render_terminal();
            String::from_utf8_lossy(rendered.expose()).into_owned()
        };
        frame.render_widget(
            Paragraph::new(text)
                .block(pane_block(
                    format!("{} — Esc closes", popup.title()),
                    true,
                    capability,
                ))
                .wrap(Wrap { trim: false }),
            area,
        );
        return;
    }
    if let Some(uri) = app.uri_popup() {
        frame.render_widget(
            Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned())
                .block(pane_block("OTP URI — Esc closes", true, capability))
                .wrap(Wrap { trim: false }),
            area,
        );
        return;
    }

    if app.mode() == Mode::Help {
        if let Some(help) = app.command_help() {
            frame.render_widget(
                Paragraph::new(help)
                    .block(pane_block("Command help", true, capability))
                    .wrap(Wrap { trim: false }),
                area,
            );
            return;
        }
        let entries = help_actions(app.help_context_mode())
            .map(|spec| {
                let bindings = spec
                    .bindings
                    .iter()
                    .map(|binding| binding.display)
                    .collect::<Vec<_>>()
                    .join(", ");
                format!(
                    "{bindings:>10}  {:<20} :{:<16} {}",
                    spec.label,
                    spec.command,
                    spec.help()
                )
            })
            .collect::<Vec<_>>();
        let lines = if area.width >= 120 {
            entries
                .chunks(2)
                .map(|chunk| Line::raw(chunk.join("    ")))
                .collect::<Vec<_>>()
        } else {
            entries.into_iter().map(Line::raw).collect::<Vec<_>>()
        };
        frame.render_widget(
            Paragraph::new(lines)
                .block(pane_block(
                    format!("Contextual help — {}", mode_title(app.help_context_mode())),
                    true,
                    capability,
                ))
                .wrap(Wrap { trim: false }),
            area,
        );
        return;
    }

    if app.mode() == Mode::Dialog
        && let Some(workflow) = app.workflow()
    {
        frame.render_widget(
            Paragraph::new(workflow.rows().join("\n"))
                .block(pane_block(workflow.title(), true, capability))
                .wrap(Wrap { trim: false }),
            area,
        );
        return;
    }

    let class = layout_class(frame.area());
    let (direction, constraints) = match class {
        LayoutClass::Narrow if app.mode() == Mode::Viewer && area.height < 10 => (
            Direction::Vertical,
            [Constraint::Length(0), Constraint::Min(1)],
        ),
        LayoutClass::Narrow => (
            Direction::Vertical,
            [Constraint::Percentage(40), Constraint::Percentage(60)],
        ),
        LayoutClass::Normal => (
            Direction::Horizontal,
            [Constraint::Percentage(35), Constraint::Percentage(65)],
        ),
        LayoutClass::Wide => (
            Direction::Horizontal,
            [Constraint::Percentage(25), Constraint::Percentage(75)],
        ),
        LayoutClass::TooSmall => return,
    };
    let panes = Layout::new(direction, constraints).split(area);
    frame.render_widget(
        Paragraph::new(sidebar_lines(app))
            .block(pane_block(
                "Passwords",
                app.focus() == PaneFocus::Sidebar,
                capability,
            ))
            .wrap(Wrap { trim: false }),
        panes[0],
    );
    if app.mode() == Mode::Viewer
        && let Some(viewer) = app.viewer()
        && large_otp_fits(app, viewer, panes[1])
    {
        let main_rows =
            Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)])
                .split(panes[1]);
        render_large_otp(
            frame,
            app.otp_display().expect("large OTP was checked"),
            main_rows[0],
            app.focus() == PaneFocus::Main,
            capability,
        );
        let lines = viewer_lines(viewer, None);
        let scroll = viewer_scroll(viewer, &lines, main_rows[1]);
        frame.render_widget(
            Paragraph::new(lines)
                .scroll((scroll, 0))
                .block(pane_block(
                    mode_title(app.mode()),
                    app.focus() == PaneFocus::Main,
                    capability,
                ))
                .wrap(Wrap { trim: false }),
            main_rows[1],
        );
        return;
    }
    if app.mode() == Mode::Viewer
        && let Some(viewer) = app.viewer()
        && let Some(display) = matching_totp_display(app, viewer)
    {
        let (otp_area, details_area) = if panes[1].height >= 6 {
            let rows =
                Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]);
            (rows[0], Some(rows[1]))
        } else {
            (panes[1], None)
        };
        if let Some(details_area) = details_area {
            render_compact_otp(
                frame,
                display,
                otp_area,
                app.focus() == PaneFocus::Main,
                capability,
            );
            let lines = viewer_lines(viewer, None);
            let scroll = viewer_scroll(viewer, &lines, details_area);
            frame.render_widget(
                Paragraph::new(lines)
                    .scroll((scroll, 0))
                    .block(pane_block(
                        mode_title(app.mode()),
                        app.focus() == PaneFocus::Main,
                        capability,
                    ))
                    .wrap(Wrap { trim: false }),
                details_area,
            );
        } else {
            render_compact_viewer(frame, app, viewer, display, otp_area, capability);
        }
        return;
    }
    let main = match app.mode() {
        Mode::Viewer => app.viewer().map_or_else(
            || Paragraph::new(main_text(app)),
            |viewer| {
                let lines = viewer_lines(viewer, app.otp_display());
                let scroll = viewer_scroll(viewer, &lines, panes[1]);
                Paragraph::new(lines).scroll((scroll, 0))
            },
        ),
        Mode::Editor => app.editor().map_or_else(
            || Paragraph::new(main_text(app)),
            |editor| {
                let focused = editor.focused_index().unwrap_or_default();
                let scroll = editor
                    .document()
                    .fields()
                    .iter()
                    .take(focused)
                    .map(|field| {
                        std::str::from_utf8(field.contents().expose())
                            .map_or(1, |value| value.split('\n').count())
                    })
                    .sum::<usize>();
                Paragraph::new(editor_lines(editor))
                    .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0))
            },
        ),
        Mode::Browser if app.git_view().is_some() => {
            Paragraph::new(git_lines(app.git_view().expect("checked Git view")))
        }
        Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
            app.grep_view().expect("checked decrypted search results"),
        )),
        _ => Paragraph::new(main_text(app)),
    };
    frame.render_widget(
        main.block(pane_block(
            mode_title(app.mode()),
            app.focus() == PaneFocus::Main,
            capability,
        ))
        .wrap(Wrap { trim: false }),
        panes[1],
    );
}

fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool {
    let Some(display) = matching_totp_display(app, viewer) else {
        return false;
    };
    let code = display.code().expose();
    let glyph_width = code.len().saturating_mul(4).saturating_sub(1);
    !code.is_empty()
        && code.iter().all(u8::is_ascii_digit)
        && usize::from(area.width.saturating_sub(2)) >= glyph_width
        && area.height >= LARGE_OTP_HEIGHT.saturating_add(3)
}

fn matching_totp_display<'a>(
    app: &'a App,
    viewer: &EntryViewer,
) -> Option<&'a crate::app::OtpDisplay> {
    let display = app.otp_display()?;
    if app.selected_entry() != Some(display.entry())
        || display.remaining_seconds().is_none()
        || display.period_seconds().is_none()
    {
        return None;
    }
    let field_id = display.field()?;
    viewer
        .document()
        .fields()
        .iter()
        .any(|field| {
            field.id() == field_id
                && field
                    .metadata()
                    .otp()
                    .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
        })
        .then_some(display)
}

fn render_large_otp(
    frame: &mut Frame,
    display: &crate::app::OtpDisplay,
    area: Rect,
    focused: bool,
    capability: ColorCapability,
) {
    let glyphs = (0..LARGE_OTP_ROWS)
        .map(|row| otp_ascii_row(display.code().expose(), row))
        .collect::<Vec<_>>();
    let remaining = display.remaining_seconds().unwrap_or_default();
    let period = display.period_seconds().unwrap_or(1);
    let inner_width = usize::from(area.width.saturating_sub(2));
    let mut lines = glyphs
        .iter()
        .map(|row| large_otp_line(row))
        .collect::<Vec<_>>();
    lines.push(Line::styled(
        countdown_bar(remaining, period, inner_width),
        Style::default().fg(ACCENT_COLOR),
    ));
    frame.render_widget(
        Paragraph::new(lines)
            .alignment(Alignment::Center)
            .block(pane_block(
                format!("TOTP code — {remaining}s remaining"),
                focused,
                capability,
            )),
        area,
    );
}

fn large_otp_line(row: &str) -> Line<'_> {
    let digit_style = Style::default()
        .fg(Color::Black)
        .bg(Color::Green)
        .add_modifier(Modifier::BOLD);
    Line::from(
        row.split(' ')
            .enumerate()
            .flat_map(|(index, digits)| {
                [
                    (index != 0).then(|| Span::raw(" ")),
                    (!digits.is_empty()).then(|| Span::styled(digits, digit_style)),
                ]
                .into_iter()
                .flatten()
            })
            .collect::<Vec<_>>(),
    )
}

fn render_compact_otp(
    frame: &mut Frame,
    display: &crate::app::OtpDisplay,
    area: Rect,
    focused: bool,
    capability: ColorCapability,
) {
    frame.render_widget(
        Paragraph::new(compact_otp_line(display))
            .alignment(Alignment::Center)
            .block(pane_block("TOTP code", focused, capability)),
        area,
    );
}

fn compact_otp_line(display: &crate::app::OtpDisplay) -> Line<'_> {
    let code = String::from_utf8_lossy(display.code().expose());
    let remaining = display.remaining_seconds().unwrap_or_default();
    Line::from(vec![
        Span::styled(
            code,
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(format!(" — {remaining}s remaining")),
    ])
}

fn render_compact_viewer(
    frame: &mut Frame,
    app: &App,
    viewer: &EntryViewer,
    display: &crate::app::OtpDisplay,
    area: Rect,
    capability: ColorCapability,
) {
    frame.render_widget(
        pane_block(
            mode_title(app.mode()),
            app.focus() == PaneFocus::Main,
            capability,
        ),
        area,
    );
    let inner = area.inner(Margin::new(1, 1));
    if inner.height == 0 {
        return;
    }
    let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
    frame.render_widget(Paragraph::new(compact_otp_line(display)), rows[0]);
    if rows[1].height == 0 {
        return;
    }
    let lines = viewer_lines(viewer, None);
    let scroll = viewer_scroll_in_content(viewer, &lines, rows[1].width, rows[1].height);
    frame.render_widget(
        Paragraph::new(lines)
            .scroll((scroll, 0))
            .wrap(Wrap { trim: false }),
        rows[1],
    );
}

fn viewer_scroll(viewer: &EntryViewer, lines: &[Line<'_>], area: Rect) -> u16 {
    viewer_scroll_in_content(
        viewer,
        lines,
        area.width.saturating_sub(2),
        area.height.saturating_sub(2),
    )
}

fn viewer_scroll_in_content(
    viewer: &EntryViewer,
    lines: &[Line<'_>],
    width: u16,
    height: u16,
) -> u16 {
    let row_heights = lines
        .iter()
        .map(|line| {
            Paragraph::new(line.clone())
                .wrap(Wrap { trim: false })
                .line_count(width)
                .max(1)
        })
        .collect::<Vec<_>>();
    let focused = viewer.focused_index().unwrap_or_default();
    let field_heights = viewer
        .document()
        .fields()
        .iter()
        .map(|field| {
            std::str::from_utf8(field.value()).map_or(1, |value| value.split('\n').count())
        })
        .collect::<Vec<_>>();
    let focused_line = field_heights.iter().take(focused).sum();
    let focused_lines = field_heights.get(focused).copied().unwrap_or(1);
    let focused_start = row_heights.iter().take(focused_line).sum();
    let focused_height = row_heights
        .iter()
        .skip(focused_line)
        .take(focused_lines)
        .sum();
    let total_rows = row_heights.iter().sum();
    let scroll = viewer.ensure_focus_visible(
        focused_start,
        focused_height,
        total_rows,
        usize::from(height),
    );
    u16::try_from(scroll).unwrap_or(u16::MAX)
}

fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
    let mut rendered = Zeroizing::new(String::with_capacity(
        code.len().saturating_mul(4).saturating_sub(1),
    ));
    for (index, digit) in code.iter().copied().enumerate() {
        if index != 0 {
            rendered.push(' ');
        }
        rendered.push_str(otp_digit_glyph(digit, row));
    }
    rendered
}

fn otp_digit_glyph(digit: u8, row: usize) -> &'static str {
    const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [
        ["000", "0 0", "0 0", "0 0", "000"],
        [" 1 ", "11 ", " 1 ", " 1 ", "111"],
        ["222", "  2", "222", "2  ", "222"],
        ["333", "  3", "333", "  3", "333"],
        ["4 4", "4 4", "444", "  4", "  4"],
        ["555", "5  ", "555", "  5", "555"],
        ["666", "6  ", "666", "6 6", "666"],
        ["777", "  7", " 7 ", " 7 ", " 7 "],
        ["888", "8 8", "888", "8 8", "888"],
        ["999", "9 9", "999", "  9", "999"],
    ];
    let index = usize::from(digit.saturating_sub(b'0'));
    GLYPHS
        .get(index)
        .and_then(|glyph| glyph.get(row))
        .copied()
        .unwrap_or("   ")
}

fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
    let label = format!("{remaining}s");
    let bar_width = width.saturating_sub(label.len().saturating_add(1));
    let period = period.max(1);
    let remaining = remaining.min(period);
    let filled = if remaining == 0 {
        0
    } else {
        usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period)))
            .unwrap_or(bar_width)
            .min(bar_width)
    };
    format!(
        "{}{} {label}",
        "█".repeat(filled),
        "░".repeat(bar_width.saturating_sub(filled))
    )
}

fn pane_block<'a>(
    title: impl Into<Line<'a>>,
    focused: bool,
    capability: ColorCapability,
) -> Block<'a> {
    let style = if focused {
        match capability {
            ColorCapability::Color => Style::default().fg(ACCENT_COLOR),
            ColorCapability::Monochrome => Style::default().add_modifier(Modifier::BOLD),
        }
    } else {
        Style::default()
    };
    Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(style)
}

fn selected_style() -> Style {
    Style::default()
        .fg(SELECTED_FOREGROUND)
        .bg(SELECTED_BACKGROUND)
        .add_modifier(Modifier::BOLD)
}

fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
    let style = selected_style();
    for span in &mut spans {
        span.style = style;
    }
    Line::from(spans).style(style)
}

fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
    let selected = app.sidebar().selected();
    let rows = app.sidebar().visible_window();
    if rows.is_empty() {
        if app.is_busy() {
            return vec![Line::from("Loading password-store tree…")];
        }
        return vec![Line::from("No password entries.")];
    }
    rows.into_iter()
        .map(|row| {
            let marker = if row.id.is_directory() {
                if row.expanded { "▼" } else { "▶" }
            } else {
                "•"
            };
            let mut indicators = String::new();
            if row.indicators.is_locked() {
                indicators.push_str(" L");
            }
            if row.indicators.is_changed() {
                indicators.push_str(" *");
            }
            if row.indicators.has_conflict() {
                indicators.push_str(" !");
            }
            let spans = vec![Span::raw(format!(
                "{}{} {}{}",
                "  ".repeat(row.depth),
                marker,
                row.name,
                indicators
            ))];
            if selected == Some(&row.id) {
                selected_line(spans)
            } else {
                Line::from(spans)
            }
        })
        .collect()
}

fn main_text(app: &App) -> String {
    match app.mode() {
        Mode::Browser => "Select an entry from the sidebar.".to_owned(),
        Mode::Viewer => app.selected_entry().map_or_else(
            || "Structured entry viewer".to_owned(),
            |path| format!("Opening {path}…"),
        ),
        Mode::Editor => "Saving structured entry…".to_owned(),
        Mode::Dialog if app.discard_confirmation() => {
            "Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
        }
        Mode::Dialog if app.hotp_confirmation() => {
            "Generate this HOTP code? This advances and commits its counter. Press y to continue, n or Esc to cancel.".to_owned()
        }
        Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
        Mode::Command => "Enter a command on the bottom line.".to_owned(),
        Mode::Help | Mode::Locked => String::new(),
    }
}

fn viewer_lines<'a>(
    viewer: &'a EntryViewer,
    otp_display: Option<&'a crate::app::OtpDisplay>,
) -> Vec<Line<'a>> {
    let focused = viewer.focused_index();
    let fields = viewer.document().display_fields();
    if fields.is_empty() {
        return vec![Line::from("This entry is empty.")];
    }

    let mut lines = Vec::new();
    for (index, field) in fields.into_iter().enumerate() {
        let metadata = field.metadata();
        let label = metadata.name().map_or_else(
            || match metadata.kind() {
                ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
                kind => format!("{kind:?}").to_ascii_lowercase(),
            },
            str::to_owned,
        );
        let values = std::str::from_utf8(field.value()).ok();
        let value_lines = values.map_or(1, |value| value.split('\n').count());
        for line_index in 0..value_lines {
            let mut spans = Vec::new();
            if line_index == 0 {
                spans.push(Span::styled(
                    format!("{label}: "),
                    Style::default()
                        .fg(ACCENT_COLOR)
                        .add_modifier(Modifier::BOLD),
                ));
            } else {
                spans.push(Span::raw("  "));
            }
            match values {
                Some("") if line_index == 0 => spans.push(Span::styled(
                    "(empty)",
                    Style::default().fg(Color::DarkGray),
                )),
                Some(value) => spans.push(Span::raw(
                    value
                        .split('\n')
                        .nth(line_index)
                        .unwrap_or_default()
                        .strip_suffix('\r')
                        .unwrap_or_else(|| value.split('\n').nth(line_index).unwrap_or_default()),
                )),
                None => spans.push(Span::styled(
                    "[non-UTF-8 value]",
                    Style::default().fg(Color::Yellow),
                )),
            }
            if line_index == 0
                && let Some(otp) = metadata.otp()
            {
                let timing = otp.period().map_or_else(
                    || format!("counter {}", otp.counter().unwrap_or_default()),
                    |period| format!("period {period}s"),
                );
                spans.push(Span::styled(
                    format!(
                        "  [{:?}, {}, {:?}, {}, {} digits, {timing}]",
                        otp.kind(),
                        otp.issuer().unwrap_or("no issuer"),
                        otp.algorithm(),
                        otp.account(),
                        otp.digits(),
                    ),
                    Style::default().fg(Color::DarkGray),
                ));
                if let Some(display) = otp_display
                    && display
                        .field()
                        .is_none_or(|display_field| display_field == field.id())
                {
                    let code = String::from_utf8_lossy(display.code().expose());
                    let validity = display.remaining_seconds().map_or_else(
                        || {
                            display
                                .counter()
                                .map_or(String::new(), |counter| format!(", counter {counter}"))
                        },
                        |remaining| format!(", {remaining}s remaining"),
                    );
                    spans.insert(
                        0,
                        Span::styled(
                            format!("code {code}{validity}  "),
                            Style::default()
                                .fg(Color::Green)
                                .add_modifier(Modifier::BOLD),
                        ),
                    );
                }
            }
            lines.push(if focused == Some(index) {
                selected_line(spans)
            } else {
                Line::from(spans)
            });
        }
    }
    lines
}

fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
    let focused = editor.focused_index();
    if editor.document().fields().is_empty() {
        return vec![Line::from(
            "This entry is empty. Press a to add its first field.",
        )];
    }

    let mut lines = Vec::new();
    for (index, field) in editor.document().fields().iter().enumerate() {
        let selected = focused == Some(index);
        let contents = editor
            .focused_contents(field.id())
            .unwrap_or_else(|| field.contents().expose());
        let label = field.metadata().name().map_or_else(
            || format!("{:?}", field.metadata().kind()),
            |name| format!("{:?} ({name})", field.metadata().kind()),
        );
        if let Ok(value) = std::str::from_utf8(contents) {
            let mut offset = 0;
            for (line_index, value_line) in value.split('\n').enumerate() {
                let value_line = value_line.strip_suffix('\r').unwrap_or(value_line);
                let mut spans = vec![Span::styled(
                    if line_index == 0 {
                        format!("#{:02} {label}: ", index + 1)
                    } else {
                        "    ".to_owned()
                    },
                    Style::default()
                        .fg(ACCENT_COLOR)
                        .add_modifier(Modifier::BOLD),
                )];
                let cursor = editor.cursor().saturating_sub(offset);
                if selected
                    && editor.is_input_active()
                    && editor.cursor() >= offset
                    && cursor <= value_line.len()
                    && value_line.is_char_boundary(cursor)
                {
                    let (before, after) = value_line.split_at(cursor);
                    spans.push(Span::raw(before));
                    spans.push(Span::styled("▏", Style::default().fg(Color::Yellow)));
                    spans.push(Span::raw(after));
                } else if value.is_empty() {
                    spans.push(Span::styled(
                        "(empty)",
                        Style::default().fg(Color::DarkGray),
                    ));
                } else {
                    spans.push(Span::raw(value_line));
                }
                lines.push(if selected {
                    selected_line(spans)
                } else {
                    Line::from(spans)
                });
                offset += value_line.len() + 1;
            }
        } else {
            let spans = vec![
                Span::styled(
                    format!("#{:02} {label}: ", index + 1),
                    Style::default()
                        .fg(ACCENT_COLOR)
                        .add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    "[non-UTF-8 field; editing will preserve bytes]",
                    Style::default().fg(Color::Yellow),
                ),
            ];
            lines.push(if selected {
                selected_line(spans)
            } else {
                Line::from(spans)
            });
        }
    }
    lines
}

fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
    if view.entries().is_empty() {
        return vec![Line::from("No decrypted entries matched.")];
    }
    let mut lines = Vec::new();
    for (index, entry) in view.entries().iter().enumerate() {
        let selected = view.selected_index() == Some(index);
        let entry_line = vec![Span::raw(format!(
            "{} {}",
            if selected { ">" } else { " " },
            entry.path()
        ))];
        lines.push(if selected {
            selected_line(entry_line)
        } else {
            Line::from(entry_line).style(
                Style::default()
                    .fg(ACCENT_COLOR)
                    .add_modifier(Modifier::BOLD),
            )
        });
        for matched in entry.lines() {
            let contents = std::str::from_utf8(matched.contents().expose())
                .unwrap_or("[non-UTF-8 matched line]");
            let prefix = if view.includes_line_numbers() {
                format!("{}: ", matched.number())
            } else {
                String::new()
            };
            let matched = vec![Span::raw(format!("    {prefix}{contents}"))];
            lines.push(if selected {
                selected_line(matched)
            } else {
                Line::from(matched)
            });
        }
    }
    lines
}

fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
    let snapshot = view.snapshot();
    let status = snapshot.status();
    let mut lines = vec![
        Line::styled(
            view.message(),
            Style::default()
                .fg(ACCENT_COLOR)
                .add_modifier(Modifier::BOLD),
        ),
        Line::raw(format!("repository: {}", snapshot.root().display())),
        Line::raw(format!("branch: {}", snapshot.branch())),
        Line::raw(format!(
            "worktree: {}",
            if status.is_clean() {
                "clean".to_owned()
            } else {
                format!(
                    "{} staged, {} unstaged",
                    status.staged().len(),
                    status.unstaged().len()
                )
            }
        )),
    ];
    if let Some(remote) = snapshot.remote() {
        lines.push(Line::raw(format!(
            "remote: {}  {}",
            remote.name(),
            remote.url()
        )));
        lines.push(Line::raw(format!(
            "relation: {} ahead, {} behind",
            remote.ahead(),
            remote.behind()
        )));
    } else {
        lines.push(Line::styled(
            "remote: not configured",
            Style::default().fg(Color::Yellow),
        ));
    }
    if !view.conflicts().is_empty() {
        lines.push(Line::styled(
            "merge conflicts:",
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        ));
        lines.extend(view.conflicts().iter().map(|conflict| {
            Line::raw(format!(
                "  {:?}: {}",
                conflict.kind(),
                conflict.path().display()
            ))
        }));
        lines.push(Line::raw(
            "Use :git resolve-local or :git resolve-remote for all listed paths.",
        ));
    }
    if let Some(details) = view.details() {
        lines.push(Line::raw(""));
        lines.extend(
            String::from_utf8_lossy(details.expose())
                .lines()
                .map(|line| Line::raw(line.to_owned())),
        );
    } else if !snapshot.recent().is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "recent commits:",
            Style::default().add_modifier(Modifier::BOLD),
        ));
        lines.extend(snapshot.recent().iter().map(|entry| {
            Line::raw(format!(
                "  {}  {}",
                &entry.id()[..entry.id().len().min(12)],
                entry.message()
            ))
        }));
    }
    lines
}

fn mode_title(mode: Mode) -> &'static str {
    match mode {
        Mode::Browser => "Browser",
        Mode::Viewer => "Viewer",
        Mode::Editor => "Editor",
        Mode::Dialog => "Dialog",
        Mode::Help => "Help",
        Mode::Command => "Command",
        Mode::Locked => "Locked",
    }
}

fn status_line(app: &App) -> Paragraph<'_> {
    Paragraph::new(status_content(app))
}

fn status_content(app: &App) -> Line<'_> {
    let busy = if app.is_busy() { " [working]" } else { "" };
    let warning = app
        .remaining_lease()
        .filter(|remaining| remaining.as_secs() <= 30)
        .map_or_else(String::new, |remaining| {
            format!(" [locks in {}s]", remaining.as_secs())
        });
    let clipboard = if app.clipboard_pending() {
        app.clipboard_remaining_seconds().map_or_else(
            || " [copying to clipboard]".to_owned(),
            |remaining| format!(" [clipboard clears in {remaining}s]"),
        )
    } else {
        String::new()
    };
    Line::from(vec![
        Span::styled(" status ", selected_style()),
        Span::raw(format!(" {}{busy}{warning}{clipboard}", app.status())),
    ])
}

fn context_line(app: &App) -> Paragraph<'static> {
    if app.mode() == Mode::Dialog && app.workflow().is_some() {
        return Paragraph::new("Tab/Shift-Tab focus  Space toggle  Ctrl-S submit  Esc cancel");
    }
    if app.grep_view().is_some() {
        return Paragraph::new("j/k result  Enter open entry  Esc close decrypted results");
    }
    let text = context_actions(app.mode())
        .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
        .collect::<Vec<_>>()
        .join("  ");
    Paragraph::new(text)
}

fn prompt_line(app: &App) -> Paragraph<'static> {
    match app.mode() {
        Mode::Command => Paragraph::new(format!(":{}", app.command_line().display()))
            .style(Style::default().fg(Color::Yellow)),
        Mode::Dialog if app.workflow().is_some() => {
            Paragraph::new("form> ").style(Style::default().fg(Color::Yellow))
        }
        Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)),
        Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
            Paragraph::new("-- INSERT --  Esc stops input; Tab changes field; C-s saves")
                .style(Style::default().fg(Color::Yellow))
        }
        _ if app.sidebar().is_editing_filter() => {
            Paragraph::new(format!("/{}", app.sidebar().filter_query()))
                .style(Style::default().fg(Color::Yellow))
        }
        _ => Paragraph::new(""),
    }
}

#[cfg(test)]
mod tests {
    use std::ffi::OsStr;

    use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};

    use super::*;
    use crate::app::Transition;
    use crate::sidebar::TestTreeNode;
    use crate::viewer::test_support::{
        fixture_document, fixture_document_from_plaintext, fixture_grep,
    };

    fn render(width: u16, height: u16, app: &App) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("test terminal");
        terminal.draw(|frame| draw(frame, app)).expect("draw");
        let buffer = terminal.backend().buffer();
        (0..height)
            .map(|y| {
                (0..width)
                    .map(|x| buffer[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn render_buffer(width: u16, height: u16, app: &App, capability: ColorCapability) -> Buffer {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("test terminal");
        terminal
            .draw(|frame| draw_with_color_capability(frame, app, capability))
            .expect("draw");
        terminal.backend().buffer().clone()
    }

    fn border_symbols(buffer: &Buffer, area: Rect) -> Vec<String> {
        let right = area.right().saturating_sub(1);
        let bottom = area.bottom().saturating_sub(1);
        let mut symbols = Vec::new();
        for x in area.left()..=right {
            symbols.push(buffer[(x, area.top())].symbol().to_owned());
            symbols.push(buffer[(x, bottom)].symbol().to_owned());
        }
        for y in area.top().saturating_add(1)..bottom {
            symbols.push(buffer[(area.left(), y)].symbol().to_owned());
            symbols.push(buffer[(right, y)].symbol().to_owned());
        }
        symbols
    }

    #[test]
    fn sizes_have_explicit_layout_classes() {
        assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall);
        assert_eq!(layout_class(Rect::new(0, 0, 40, 7)), LayoutClass::TooSmall);
        assert_eq!(layout_class(Rect::new(0, 0, 60, 20)), LayoutClass::Narrow);
        assert_eq!(layout_class(Rect::new(0, 0, 100, 20)), LayoutClass::Normal);
        assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide);
    }

    #[test]
    fn large_otp_inverts_only_the_digits_that_form_each_glyph() {
        let line = large_otp_line("1  22");
        assert_eq!(line.to_string(), "1  22");
        for span in line.spans {
            if span.content == " " {
                assert_eq!(span.style, Style::default());
            } else {
                assert_eq!(span.style.fg, Some(Color::Black));
                assert_eq!(span.style.bg, Some(Color::Green));
                assert!(span.style.add_modifier.contains(Modifier::BOLD));
            }
        }
    }

    #[test]
    fn color_capability_has_a_complete_monochrome_fallback() {
        assert_eq!(
            ColorCapability::from_environment(Some(OsStr::new("xterm-256color")), false),
            ColorCapability::Color
        );
        assert_eq!(
            ColorCapability::from_environment(Some(OsStr::new("dumb")), false),
            ColorCapability::Monochrome
        );
        assert_eq!(
            ColorCapability::from_environment(Some(OsStr::new("xterm")), true),
            ColorCapability::Monochrome
        );

        let app = App::new();
        let backend = TestBackend::new(100, 20);
        let mut terminal = Terminal::new(backend).expect("test terminal");
        terminal
            .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Monochrome))
            .expect("monochrome draw");
        assert!(
            terminal
                .backend()
                .buffer()
                .content
                .iter()
                .all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset })
        );
        assert!(
            terminal
                .backend()
                .buffer()
                .content
                .iter()
                .any(|cell| cell.modifier.contains(Modifier::REVERSED))
        );

        terminal
            .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color))
            .expect("color draw");
        assert!(
            terminal
                .backend()
                .buffer()
                .content
                .iter()
                .any(|cell| { cell.fg != Color::Reset || cell.bg != Color::Reset })
        );
    }

    #[test]
    fn pane_focus_changes_only_the_shared_accent_border_style() {
        let mut app = App::new();
        let sidebar = Rect::new(0, 0, 35, 17);
        let main = Rect::new(35, 0, 65, 17);
        let sidebar_focused = render_buffer(100, 20, &app, ColorCapability::Color);
        assert_eq!(sidebar_focused[(0, 0)].fg, ACCENT_COLOR);
        assert_eq!(sidebar_focused[(0, 0)].bg, Color::Reset);
        assert!(!sidebar_focused[(0, 0)].modifier.contains(Modifier::BOLD));
        assert_eq!(sidebar_focused[(35, 0)].fg, Color::Reset);
        assert!(!sidebar_focused[(35, 0)].modifier.contains(Modifier::BOLD));

        app.dispatch(crate::action::Action::FocusNext);
        let main_focused = render_buffer(100, 20, &app, ColorCapability::Color);
        assert_eq!(main_focused[(0, 0)].fg, Color::Reset);
        assert!(!main_focused[(0, 0)].modifier.contains(Modifier::BOLD));
        assert_eq!(main_focused[(35, 0)].fg, ACCENT_COLOR);
        assert_eq!(main_focused[(35, 0)].bg, Color::Reset);
        assert!(!main_focused[(35, 0)].modifier.contains(Modifier::BOLD));
        assert_eq!(
            border_symbols(&sidebar_focused, sidebar),
            border_symbols(&main_focused, sidebar)
        );
        assert_eq!(
            border_symbols(&sidebar_focused, main),
            border_symbols(&main_focused, main)
        );

        let main_monochrome = render_buffer(100, 20, &app, ColorCapability::Monochrome);
        assert_eq!(main_monochrome[(0, 0)].fg, Color::Reset);
        assert_eq!(main_monochrome[(35, 0)].fg, Color::Reset);
        assert!(!main_monochrome[(0, 0)].modifier.contains(Modifier::BOLD));
        assert!(main_monochrome[(35, 0)].modifier.contains(Modifier::BOLD));

        app.dispatch(crate::action::Action::Help);
        let help = render_buffer(100, 20, &app, ColorCapability::Color);
        assert_eq!(help[(0, 0)].fg, ACCENT_COLOR);
        assert_eq!(help[(0, 0)].bg, Color::Reset);
    }

    #[test]
    fn minimum_size_message_is_clear() {
        let output = render(39, 8, &App::new());
        assert!(output.contains("Terminal too small"));
        assert!(output.contains("40×8"));
    }

    #[test]
    fn normal_shell_contains_every_required_region() {
        let output = render(100, 20, &App::new());
        assert!(output.contains("Passwords"));
        assert!(output.contains("Browser"));
        assert!(output.contains("status"));
        assert!(output.contains("? help"));
    }

    #[test]
    fn narrow_and_wide_shells_render_without_losing_panes() {
        for width in [60, 140] {
            let output = render(width, 20, &App::new());
            assert!(output.contains("Passwords"));
            assert!(output.contains("Browser"));
        }
    }

    #[test]
    fn help_is_generated_from_the_action_registry() {
        let mut app = App::new();
        assert!(app.transition(Transition::OpenHelp));
        let output = render(140, 35, &app);
        for spec in crate::action::help_actions(Mode::Browser) {
            assert!(output.contains(spec.label));
            assert!(output.contains(spec.command));
        }
        assert!(output.contains("d d"));
        assert!(output.contains("confirmation"));
        assert!(output.contains("g p"));
        assert!(!output.contains("edit field"));

        app.dispatch(crate::action::Action::Cancel);
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(crate::action::Action::Help);
        let viewer_help = render(140, 35, &app);
        assert!(viewer_help.contains("edit entry"));
        assert!(viewer_help.contains("copy field"));
        assert!(!viewer_help.contains("insert entry"));
    }

    #[test]
    fn command_prompt_help_and_confirmation_are_rendered_without_secrets() {
        let mut app = App::new();
        app.dispatch(crate::action::Action::Command);
        for character in "otp validate otpauth://totp/test?secret=NEVER-SHOW".chars() {
            app.handle_command_input(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        let prompt = render(120, 24, &app);
        assert!(prompt.contains("hidden secret-bearing arguments"));
        assert!(!prompt.contains("NEVER-SHOW"));

        app.dispatch(crate::action::Action::Cancel);
        app.dispatch(crate::action::Action::Command);
        for character in "help git".chars() {
            app.handle_command_input(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        app.handle_command_input(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::NONE,
        );
        assert!(render(120, 30, &app).contains("Usage: git"));

        app.dispatch(crate::action::Action::Cancel);
        app.dispatch(crate::action::Action::Command);
        for character in "remove old/entry".chars() {
            app.handle_command_input(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        app.handle_command_input(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::NONE,
        );
        let confirmation = render(120, 24, &app);
        assert!(confirmation.contains("Remove entry or folder"));
        assert!(confirmation.contains("Confirm permanent removal: no"));
        assert!(confirmation.contains("Ctrl-S submit"));
    }

    #[test]
    fn workflow_dialog_is_keyboard_discoverable_and_masks_inserted_secrets() {
        let mut app = App::new();
        app.dispatch(crate::action::Action::InsertEntry);
        for character in "nested/account".chars() {
            app.handle_workflow_input(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        app.handle_workflow_input(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::NONE,
        );
        app.handle_workflow_input(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::NONE,
        );
        for character in "never-render-this".chars() {
            app.handle_workflow_input(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        let output = render(100, 24, &app);
        assert!(output.contains("Insert entry"));
        assert!(output.contains("nested/account"));
        assert!(output.contains("Ctrl-S submit"));
        assert!(output.contains("••••••••"));
        assert!(!output.contains("never-render-this"));
    }

    #[test]
    fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
        let mut app = App::new();
        app.resize(100, 20);
        app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
            path: "personal".to_owned(),
            name: "personal".to_owned(),
            directory: true,
            indicators: ironstorage::read::TreeNodeIndicators::default(),
            children: vec![TestTreeNode {
                path: "personal/咖啡".to_owned(),
                name: "咖啡".to_owned(),
                directory: false,
                indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true),
                children: vec![],
            }],
        }]);
        app.sidebar_mut().move_child();
        let output = render(100, 20, &app);
        assert!(output.contains("▼ personal"));
        assert!(output.contains('咖'));
        assert!(output.contains("L * !"));
    }

    #[test]
    fn scrolling_focus_filter_prompt_and_resize_are_rendered() {
        let mut app = App::new();
        app.resize(60, 10);
        app.sidebar_mut().replace_test_tree(
            (0..20)
                .map(|index| TestTreeNode {
                    path: format!("entry-{index:02}"),
                    name: format!("entry-{index:02}"),
                    directory: false,
                    indicators: ironstorage::read::TreeNodeIndicators::default(),
                    children: vec![],
                })
                .collect(),
        );
        app.sidebar_mut().move_last();
        app.sidebar_mut().begin_filter();
        app.sidebar_mut().push_filter_character('咖');
        let narrow = render(60, 10, &app);
        assert!(narrow.contains("entry-19"));
        assert!(narrow.contains("/咖"));
        assert!(!narrow.contains("entry-00"));

        app.dispatch(crate::action::Action::FocusNext);
        let wide = render(140, 20, &app);
        assert!(wide.contains("Browser"));
    }

    #[test]
    fn viewer_scroll_changes_only_when_focus_crosses_the_rendered_viewport() {
        let mut document = fixture_document("email/personal");
        for index in 0..5 {
            let insertion = document.fields().len();
            document
                .add(
                    insertion,
                    ironstorage::document::EntryFieldDraft::field(
                        format!("extra-{index}"),
                        format!("value-{index}").into_bytes(),
                    )
                    .expect("field draft"),
                )
                .expect("append test field");
        }

        let mut app = App::new();
        app.open_test_document("many/fields", document);
        for action in [
            crate::action::Action::FocusNext,
            crate::action::Action::Next,
            crate::action::Action::FocusNext,
            crate::action::Action::Next,
            crate::action::Action::FocusNext,
            crate::action::Action::Next,
            crate::action::Action::FocusNext,
        ] {
            render(100, 20, &app);
            assert_eq!(app.viewer().expect("viewer").scroll(), 0);
            app.dispatch(action);
        }

        let mut small = App::new();
        let mut document = fixture_document("email/personal");
        for index in 0..5 {
            let insertion = document.fields().len();
            document
                .add(
                    insertion,
                    ironstorage::document::EntryFieldDraft::field(
                        format!("extra-{index}"),
                        format!("value-{index}").into_bytes(),
                    )
                    .expect("field draft"),
                )
                .expect("append test field");
        }
        small.open_test_document("many/fields", document);
        for (expected_scroll, action) in [
            (0, crate::action::Action::Next),
            (0, crate::action::Action::FocusNext),
            (0, crate::action::Action::Next),
            (1, crate::action::Action::FocusNext),
            (2, crate::action::Action::Next),
        ] {
            let output = render(40, 8, &small);
            assert!(output.contains("Viewer"));
            assert_eq!(small.viewer().expect("viewer").scroll(), expected_scroll);
            small.dispatch(action);
        }
        small.dispatch(crate::action::Action::Previous);
        render(40, 8, &small);
        assert_eq!(small.viewer().expect("viewer").scroll(), 2);
        small.dispatch(crate::action::Action::FocusPrevious);
        render(40, 8, &small);
        assert_eq!(small.viewer().expect("viewer").scroll(), 2);
        small.dispatch(crate::action::Action::Previous);
        render(40, 8, &small);
        assert_eq!(small.viewer().expect("viewer").scroll(), 2);
        small.dispatch(crate::action::Action::FocusPrevious);
        render(40, 8, &small);
        assert_eq!(small.viewer().expect("viewer").scroll(), 1);

        render(100, 20, &small);
        assert_eq!(small.viewer().expect("viewer").scroll(), 0);
    }

    #[test]
    fn wrapped_viewer_fields_keep_the_selected_field_on_screen() {
        let mut document = fixture_document("email/personal");
        document
            .add(
                1,
                ironstorage::document::EntryFieldDraft::field(
                    "url",
                    "xxxxxx ".repeat(15).into_bytes(),
                )
                .expect("field draft"),
            )
            .expect("prepend wrapped field");
        let mut app = App::new();
        app.open_test_document("many/wrapped-fields", document);

        render(40, 8, &app);
        assert_eq!(app.viewer().expect("viewer").scroll(), 0);
        app.dispatch(crate::action::Action::FocusNext);
        app.dispatch(crate::action::Action::FocusNext);
        let wrapped = render(40, 8, &app);
        assert!(wrapped.contains("url: xxxxxx"));
        let wrapped_scroll = app.viewer().expect("viewer").scroll();
        app.dispatch(crate::action::Action::FocusNext);
        render(40, 8, &app);
        assert_eq!(
            app.viewer()
                .and_then(crate::viewer::EntryViewer::focused_field)
                .map(|field| field.metadata().kind()),
            Some(ironstorage::document::EntryFieldKind::Url)
        );
        assert!(app.viewer().expect("viewer").scroll() > wrapped_scroll);

        render(140, 20, &app);
        assert_eq!(app.viewer().expect("viewer").scroll(), 0);
    }

    #[test]
    fn lease_warning_is_concise_and_never_contains_entry_state() {
        let mut app = App::new();
        app.update_remaining_lease(Some(std::time::Duration::from_secs(30)));
        let output = render(100, 20, &app);
        assert!(output.contains("locks in 30s"));
    }

    #[test]
    fn status_renders_the_active_clipboard_deadline() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        let presentation = match app.dispatch(crate::action::Action::Copy) {
            crate::app::AppEffect::CopyFocused { presentation, .. } => presentation,
            effect => panic!("unexpected effect: {effect:?}"),
        };
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::ClipboardStarted {
                presentation,
                deadline: std::time::Instant::now() + std::time::Duration::from_secs(45),
            }),
        });

        let output = render(100, 20, &app);
        assert!(output.contains("Secret copied"));
        assert!(output.contains("clipboard clears in 45s"));
    }

    #[test]
    fn viewer_renders_storage_sensitive_fields_and_dynamic_unicode_fields() {
        let mut app = App::new();
        app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
        for width in [60, 100, 140] {
            let output = render(width, 20, &app);
            assert!(output.contains("password: pässwörd-猫"));
            assert!(output.contains("login:"));
            assert!(output.contains('用'));
            assert!(output.contains("@example.test"));
            assert!(output.contains("notes:"));
        }
        assert!(!format!("{app:?}").contains("pässwörd-猫"));
    }

    #[test]
    fn multiline_fields_render_copy_and_select_as_one_tui_field() {
        let (_store, document) = fixture_document_from_plaintext(
            "documents/multiline",
            b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n",
        );
        assert_eq!(document.fields().len(), 3);
        let mut app = App::new();
        app.open_test_document("documents/multiline", document);
        app.dispatch(crate::action::Action::FocusNext);
        app.dispatch(crate::action::Action::FocusNext);

        let viewer = app.viewer().expect("viewer");
        assert_eq!(
            viewer.copy_focused().expect("multiline copy").expose(),
            b"Recovery codes:\none\ntwo"
        );
        let lines = viewer_lines(viewer, None);
        assert_eq!(lines.len(), 5);
        for line in &lines[2..5] {
            assert!(line.spans.iter().all(|span| {
                span.style.fg == Some(SELECTED_FOREGROUND)
                    && span.style.bg == Some(SELECTED_BACKGROUND)
            }));
        }
        let rendered = render(80, 14, &app);
        for expected in ["comments: Recovery codes:", "one", "two"] {
            assert!(rendered.contains(expected), "missing {expected}");
        }
    }

    #[test]
    fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() {
        let (_store, document) = fixture_document_from_plaintext(
            "metadata/account",
            concat!(
                "vault-secret\n",
                "autoType_enabled: true\n",
                "icon: Internet\n",
                "icon: Custom Icon\n",
                "title: Café\n",
                "custom: hidden-value\n",
                "free-form note value\n",
                "empty:\n",
                "unicode: 密碼-猫\n",
                "otp: otpauth://totp/IronStorage:alice?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage\n",
                "long: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz\n",
            )
            .as_bytes(),
        );
        let mut app = App::new();
        app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
            path: "metadata/account".to_owned(),
            name: "account".to_owned(),
            directory: false,
            indicators: ironstorage::read::TreeNodeIndicators::default(),
            children: vec![],
        }]);
        assert!(matches!(
            app.dispatch(crate::action::Action::Activate),
            crate::app::AppEffect::AuthenticateEntry(entry) if entry == "metadata/account"
        ));
        assert!(app.authentication_granted("metadata/account".to_owned()));
        let token = app.begin_request();
        assert_eq!(
            app.apply_result(crate::app::AsyncResult {
                token,
                payload: Ok(crate::app::AsyncPayload::DocumentLoaded {
                    entry: "metadata/account".to_owned(),
                    document: Box::new(document),
                }),
            }),
            crate::app::ResultDisposition::Applied
        );

        let all_values = viewer_lines(app.viewer().expect("viewer"), None)
            .iter()
            .flat_map(|line| line.spans.iter())
            .map(|span| span.content.as_ref())
            .collect::<String>();
        for expected in [
            "vault-secret",
            "autoType_enabled: true",
            "icon: Internet",
            "icon: Custom Icon",
            "title: Café",
            "custom: hidden-value",
            "free-form note value",
            "empty: (empty)",
            "unicode: 密碼-猫",
            "JBSWY3DPEHPK3PXP",
            "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
        ] {
            assert!(all_values.contains(expected), "missing {expected}");
        }

        assert!(render(140, 30, &app).contains("vault-secret"));
        app.dispatch(crate::action::Action::FocusNext);
        assert!(render(140, 30, &app).contains("vault-secret"));
        app.dispatch(crate::action::Action::Help);
        app.dispatch(crate::action::Action::Cancel);
        assert!(render(140, 30, &app).contains("vault-secret"));

        app.dispatch(crate::action::Action::CloseEntry);
        assert!(!render(140, 30, &app).contains("vault-secret"));

        app.open_test_document(
            "metadata/account",
            fixture_document_from_plaintext("metadata/account-locked", b"lock-secret\n").1,
        );
        app.forced_relock("Authentication expired");
        let locked = render(140, 20, &app);
        assert!(!locked.contains("lock-secret"));
        assert!(!locked.contains("autoType_enabled: true"));
    }

    #[test]
    fn focus_changes_keep_values_visible_and_lock_removes_them() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        assert!(render(100, 20, &app).contains("correct horse fixture"));

        app.dispatch(crate::action::Action::FocusNext);
        assert!(render(100, 20, &app).contains("correct horse fixture"));

        app.forced_relock("test lock");
        let locked = render(100, 20, &app);
        assert!(!locked.contains("correct horse fixture"));
        assert!(locked.contains("locked"));
    }

    #[test]
    fn otp_metadata_and_authenticated_uri_are_visible() {
        let mut app = App::new();
        app.open_test_document("otp/totp", fixture_document("otp/totp"));
        app.dispatch(crate::action::Action::FocusNext);
        let output = render(120, 20, &app);
        assert!(output.contains("Totp"));
        assert!(output.contains("IronStorage"));
        assert!(output.contains("alice@example.test"));
        assert!(output.contains("period 30s"));
        assert!(output.contains("JBSWY3DPEHPK3PXP"));
    }

    #[test]
    fn otp_code_qr_resize_and_lock_lifecycle_clear_after_lock() {
        let mut app = App::new();
        let document = fixture_document("otp/totp");
        let wrong_field = document.fields()[0].id();
        let otp_field = document
            .fields()
            .iter()
            .find(|field| field.metadata().otp().is_some())
            .expect("OTP field")
            .id();
        app.open_test_document("otp/totp", document);
        assert_eq!(
            app.begin_totp_refresh(),
            Some(("otp/totp".to_owned(), otp_field))
        );
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
                entry: "otp/totp".to_owned(),
                field: Some(wrong_field),
                code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
                validity: ironstorage::otp::OtpCodeValidity::Timed {
                    valid_until: 72,
                    period: 30,
                },
                observed_at: 60,
                clipboard: false,
                tree: None,
            }),
        });
        assert!(!render(120, 20, &app).contains("123456"));
        app.dispatch(crate::action::Action::FocusNext);
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
                entry: "otp/totp".to_owned(),
                field: Some(otp_field),
                code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
                validity: ironstorage::otp::OtpCodeValidity::Timed {
                    valid_until: 72,
                    period: 30,
                },
                observed_at: 60,
                clipboard: false,
                tree: None,
            }),
        });
        app.dispatch(crate::action::Action::FocusPrevious);
        let large = render(120, 20, &app);
        assert!(large.contains("TOTP code — 12s remaining"));
        assert!(large.contains("222 333"));
        assert!(!large.contains("code 123456"));
        assert!(large.contains("JBSWY3DPEHPK3PXP"));
        assert_eq!(app.viewer().expect("viewer").scroll(), 0);
        let full_bar = large.matches('█').count();

        let compact = render(60, 16, &app);
        assert!(compact.contains("123456 — 12s remaining"));
        assert!(!compact.contains("TOTP code —"));
        assert_eq!(app.viewer().expect("viewer").scroll(), 0);

        app.observe_time(66);
        let progressed = render(120, 20, &app);
        assert!(progressed.contains("TOTP code — 6s remaining"));
        let progressed_bar = progressed.matches('█').count();
        assert!(progressed_bar < full_bar);
        assert!(progressed_bar > 0);

        app.observe_time(72);
        let expired = render(120, 20, &app);
        assert!(expired.contains("0s remaining"));
        assert_eq!(expired.matches('█').count(), 0);
        assert_eq!(
            app.begin_totp_refresh(),
            Some(("otp/totp".to_owned(), otp_field))
        );
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
                entry: "otp/totp".to_owned(),
                field: Some(otp_field),
                code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
                validity: ironstorage::otp::OtpCodeValidity::Timed {
                    valid_until: 102,
                    period: 30,
                },
                observed_at: 72,
                clipboard: false,
                tree: None,
            }),
        });
        let refreshed = render(120, 20, &app);
        assert!(refreshed.contains("TOTP code — 30s remaining"));
        assert!(refreshed.contains("666 555"));
        assert!(refreshed.contains("333 222"));
        assert!(!refreshed.contains("222 333"));
        assert!(refreshed.matches('█').count() > full_bar);
        let minimum = render(40, 8, &app);
        assert!(minimum.contains("654321 — 30s remaining"));
        assert!(minimum.contains("password:"));
        assert!(!minimum.contains("••••••••"));
        assert_eq!(app.viewer().expect("viewer").scroll(), 0);

        let payload = ironstorage::repository::SecretBytes::new(
            b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
        );
        let qr = ironstorage::presentation::QrMatrix::encode(&payload).expect("QR");
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::OtpUriFinished {
                entry: "otp/totp".to_owned(),
                presentation: crate::app::OtpPresentationTarget::Qr,
                payload,
                qr: Some(qr),
            }),
        });
        let resized = render(40, 8, &app);
        assert!(resized.contains("too small for OTP QR"));
        assert!(!resized.contains("NEVER-RENDER"));
        assert!(matches!(
            app.dispatch(crate::action::Action::Lock),
            crate::app::AppEffect::ManualLock
        ));
        let locked = render(120, 20, &app);
        assert!(!locked.contains("123456"));
        assert!(!locked.contains("666 555"));
        assert!(!locked.contains("NEVER-RENDER"));
    }

    #[test]
    fn selected_fields_status_and_results_keep_one_high_contrast_style() {
        let mut app = App::new();
        let document = fixture_document("otp/totp");
        let otp_field = document
            .fields()
            .iter()
            .find(|field| field.metadata().otp().is_some())
            .expect("OTP field")
            .id();
        app.open_test_document("otp/totp", document);
        while app
            .viewer()
            .and_then(EntryViewer::focused_field)
            .map(|field| field.id())
            != Some(otp_field)
        {
            app.dispatch(crate::action::Action::FocusNext);
        }
        let token = app.begin_request();
        app.apply_result(crate::app::AsyncResult {
            token,
            payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
                entry: "otp/totp".to_owned(),
                field: Some(otp_field),
                code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
                validity: ironstorage::otp::OtpCodeValidity::Timed {
                    valid_until: 90,
                    period: 30,
                },
                observed_at: 60,
                clipboard: false,
                tree: None,
            }),
        });

        let viewer = app.viewer().expect("viewer");
        let lines = viewer_lines(viewer, app.otp_display());
        let selected = &lines[viewer.focused_index().expect("focused field")];
        assert!(
            selected
                .spans
                .iter()
                .any(|span| span.content.contains("code 123456"))
        );
        assert!(selected.spans.iter().all(|span| {
            span.style.fg == Some(SELECTED_FOREGROUND)
                && span.style.bg == Some(SELECTED_BACKGROUND)
                && span.style.add_modifier.contains(Modifier::BOLD)
        }));

        let editor = EntryEditor::new(fixture_document("email/personal"));
        let editor_selected = &editor_lines(&editor)[0];
        assert!(editor_selected.spans.iter().all(|span| {
            span.style.fg == Some(SELECTED_FOREGROUND)
                && span.style.bg == Some(SELECTED_BACKGROUND)
                && span.style.add_modifier.contains(Modifier::BOLD)
        }));

        app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
            path: "otp/totp".to_owned(),
            name: "totp".to_owned(),
            directory: false,
            indicators: ironstorage::read::TreeNodeIndicators::default(),
            children: vec![],
        }]);
        let sidebar_selected = &sidebar_lines(&app)[0];
        assert!(sidebar_selected.spans.iter().all(|span| {
            span.style.fg == Some(SELECTED_FOREGROUND)
                && span.style.bg == Some(SELECTED_BACKGROUND)
                && span.style.add_modifier.contains(Modifier::BOLD)
        }));

        let search = crate::search::GrepView::new(fixture_grep());
        let search_lines = grep_lines(&search);
        let selected_search_lines = 1 + search.entries()[0].lines().len();
        assert!(
            search_lines[..selected_search_lines]
                .iter()
                .flat_map(|line| &line.spans)
                .all(|span| {
                    span.style.fg == Some(SELECTED_FOREGROUND)
                        && span.style.bg == Some(SELECTED_BACKGROUND)
                        && span.style.add_modifier.contains(Modifier::BOLD)
                })
        );

        let status = status_content(&app);
        assert_eq!(status.spans[0].style, selected_style());

        let backend = TestBackend::new(60, 16);
        let mut terminal = Terminal::new(backend).expect("test terminal");
        terminal
            .draw(|frame| {
                draw_with_color_capability(frame, &app, ColorCapability::Monochrome);
            })
            .expect("monochrome viewer draw");
        let buffer = terminal.backend().buffer();
        assert!(
            buffer
                .content
                .iter()
                .any(|cell| cell.modifier.contains(Modifier::REVERSED))
        );
        assert!(
            buffer
                .content
                .iter()
                .all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset)
        );
    }

    #[test]
    fn closing_a_document_preserves_the_sidebar_selection() {
        let mut app = App::new();
        app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
            path: "email/personal".to_owned(),
            name: "personal".to_owned(),
            directory: false,
            indicators: ironstorage::read::TreeNodeIndicators::default(),
            children: vec![],
        }]);
        let selected = app.sidebar().selected().cloned();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(crate::action::Action::CloseEntry);
        assert_eq!(app.mode(), Mode::Browser);
        assert_eq!(app.sidebar().selected(), selected.as_ref());
        assert!(app.viewer().is_none());
    }

    #[test]
    fn editor_shows_sensitive_input_while_the_entry_is_unlocked() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(crate::action::Action::EditEntry);
        app.dispatch(crate::action::Action::BeginInput);
        app.handle_editor_input(crossterm::event::KeyCode::Char('x'));

        for width in [60, 100, 140] {
            let visible = render(width, 20, &app);
            assert!(visible.contains("correct horse fixturex"));
            assert!(!visible.contains("••••••••"));
        }

        assert!(!format!("{app:?}").contains("correct horse fixturex"));
    }

    #[test]
    fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(crate::action::Action::EditEntry);
        app.dispatch(crate::action::Action::AddField);
        app.dispatch(crate::action::Action::Cancel);
        assert_eq!(app.mode(), Mode::Dialog);
        assert!(app.discard_confirmation());
        assert!(render(100, 20, &app).contains("Discard all unsaved edits"));

        app.dispatch(crate::action::Action::KeepEditing);
        assert_eq!(app.mode(), Mode::Editor);
        app.dispatch(crate::action::Action::Cancel);
        app.dispatch(crate::action::Action::ConfirmDiscard);
        assert_eq!(app.mode(), Mode::Browser);
        assert!(app.editor().is_none());

        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(crate::action::Action::EditEntry);
        app.dispatch(crate::action::Action::AddField);
        app.dispatch(crate::action::Action::Cancel);
        app.forced_relock("test expiry");
        assert_eq!(app.mode(), Mode::Locked);
        assert!(app.editor().is_none());
        assert!(app.status().contains("unsaved edits were discarded"));
    }
}
