use std::collections::HashMap;

use chrono::Local;
use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};

use crate::{
    app::{App, FormKind, InputKind, Mode, form_choices},
    date::format_when,
    filter,
    model::{Aggregate, Item, ViewColumn, ViewKind},
};

mod theme;

use theme::{Palette, palette};

pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
    let colors = palette(&app.preferences.theme);
    frame.render_widget(
        Block::default().style(Style::default().bg(Color::Black)),
        frame.area(),
    );
    let height = frame.area().height;
    if height < 10 || frame.area().width < 40 {
        frame.render_widget(
            Paragraph::new("Rogue Agenda needs at least 40×10")
                .style(Style::default().fg(colors.accent).bg(colors.primary))
                .alignment(Alignment::Center),
            frame.area(),
        );
        return;
    }
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(if app.preferences.show_command_bar {
                2
            } else {
                0
            }),
            Constraint::Length(1),
            Constraint::Min(3),
            Constraint::Length(1),
            Constraint::Length(2),
        ])
        .split(frame.area());
    draw_header(frame, app, chunks[0]);
    draw_view_tabs(frame, app, chunks[1]);
    draw_items(frame, app, chunks[2]);
    draw_status(frame, app, chunks[3]);
    if app.preferences.show_command_bar {
        draw_commands(frame, app, chunks[4]);
    } else {
        app.command_regions.clear();
    }
    draw_overlay(frame, app);
}

fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    let alarms = app.db.due_alarm_count().unwrap_or(0);
    let now = Local::now().to_rfc3339();
    let right = format!(
        "{}{}",
        formatted_when(app, Some(&now)),
        if alarms > 0 {
            format!("  ALARM:{alarms}")
        } else {
            String::new()
        }
    );
    let width = area.width as usize;
    let left1 = format!(
        "File: {}{}",
        app.path.display(),
        if app.document_settings.description.is_empty() {
            String::new()
        } else {
            format!(" — {}", app.document_settings.description)
        }
    );
    let pad = width.saturating_sub(left1.chars().count() + right.chars().count());
    let line1 = format!("{left1}{}{right}", " ".repeat(pad));
    let line2 = format!(
        "View: {}{}{}",
        app.current_view().name,
        if app.search.is_empty() {
            String::new()
        } else {
            format!("  /{}", app.search)
        },
        app.recording
            .as_ref()
            .map(|recording| format!("  LEARN:{}", recording.name))
            .unwrap_or_default()
    );
    frame.render_widget(
        Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
            Style::default()
                .fg(Color::White)
                .bg(colors.primary)
                .add_modifier(Modifier::BOLD),
        ),
        area,
    );
}

fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    app.view_regions.clear();
    let mut x = area.x;
    frame.render_widget(
        Block::default().style(Style::default().bg(colors.canvas)),
        area,
    );
    for (i, v) in app.views.iter().enumerate() {
        let w = (v.name.chars().count() + 2).min(20) as u16;
        if x + w > area.right() {
            break;
        }
        let r = Rect::new(x, area.y, w, 1);
        app.view_regions.push(r);
        let st = if i == app.view_index {
            Style::default()
                .fg(colors.primary)
                .bg(Color::White)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::White).bg(colors.primary)
        };
        frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
        x += w + 1;
    }
}

fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    frame.render_widget(
        Block::default().style(Style::default().bg(colors.canvas)),
        area,
    );
    app.item_rows.clear();
    app.item_columns.clear();
    if app.items.is_empty() {
        frame.render_widget(
            Paragraph::new(if app.search.is_empty() {
                "  No items. Press Insert or n to capture one."
            } else {
                "  No items match this search. Press Esc to clear it."
            })
            .style(Style::default().fg(colors.canvas_heading).bg(colors.canvas)),
            area,
        );
        return;
    }
    if app.current_view().kind == ViewKind::Datebook {
        draw_datebook(frame, app, area);
        return;
    }
    let mut base_columns = app
        .current_view()
        .columns
        .iter()
        .cloned()
        .enumerate()
        .collect::<Vec<_>>();
    if area.width < 70 && base_columns.len() > 2 {
        let item = base_columns
            .iter()
            .find(|(_, column)| column.field == "item")
            .cloned()
            .unwrap_or_else(|| base_columns[0].clone());
        let detail = base_columns
            .iter()
            .find(|(_, column)| column.field == "when")
            .cloned()
            .or_else(|| {
                base_columns
                    .iter()
                    .find(|(_, column)| column.field != item.1.field)
                    .cloned()
            });
        base_columns = vec![item];
        if let Some(detail) = detail {
            base_columns.push(detail)
        }
    }
    let columns = base_columns
        .into_iter()
        .flat_map(|(base_index, column)| {
            let percent = column.percent_total.then(|| DisplayColumn {
                column: column.clone(),
                percent: true,
                base_index,
            });
            std::iter::once(DisplayColumn {
                column,
                percent: false,
                base_index,
            })
            .chain(percent)
        })
        .collect::<Vec<_>>();
    let widths = column_widths(&columns, area.width);
    let mut x = area.x;
    for (column, width) in columns.iter().zip(&widths) {
        app.item_columns.push((
            Rect::new(
                x,
                area.y,
                (*width).min(u16::MAX as usize) as u16,
                area.height,
            ),
            column.base_index,
        ));
        x = x.saturating_add((*width).min(u16::MAX as usize) as u16 + 1);
    }
    let mut display: Vec<DisplayLine> = vec![];
    let sections = app.current_view().sections.clone();
    if sections.is_empty() {
        display.push(DisplayLine::Header);
        for index in 0..app.items.len() {
            display.push(DisplayLine::Item {
                index,
                section: (0..app.items.len()).collect(),
            });
        }
        if columns
            .iter()
            .any(|column| column.column.aggregate != Aggregate::None)
        {
            display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
        }
    } else {
        for section in sections {
            display.push(DisplayLine::Section(format!(
                "{}{}",
                if section.collapsed { "▸ " } else { "▾ " },
                section.heading
            )));
            if section.collapsed {
                continue;
            }
            display.push(DisplayLine::Header);
            let indices = app
                .items
                .iter()
                .enumerate()
                .filter(|(_, item)| filter::matches(&section.filter_expr, item).unwrap_or(false))
                .map(|(i, _)| i)
                .collect::<Vec<_>>();
            for index in &indices {
                display.push(DisplayLine::Item {
                    index: *index,
                    section: indices.clone(),
                });
            }
            if columns
                .iter()
                .any(|column| column.column.aggregate != Aggregate::None)
            {
                display.push(DisplayLine::Aggregate(indices));
            }
        }
    }
    let selected_line = display
        .iter()
        .position(|line| matches!(line,DisplayLine::Item { index, .. } if *index==app.selected))
        .unwrap_or(0);
    let visible = area.height as usize;
    if selected_line < app.scroll {
        app.scroll = selected_line
    }
    if selected_line >= app.scroll + visible {
        app.scroll = selected_line + 1 - visible
    }
    let mut lines = vec![];
    for (shown, line) in display.iter().skip(app.scroll).take(visible).enumerate() {
        match line {
            DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
                format!("── {heading} "),
                Style::default()
                    .fg(colors.canvas_heading)
                    .bg(colors.canvas)
                    .add_modifier(Modifier::BOLD),
            ))),
            DisplayLine::Header => lines.push(cells_line(
                columns.iter().map(DisplayColumn::heading).collect(),
                &widths,
                Style::default()
                    .fg(colors.canvas_heading)
                    .bg(colors.canvas)
                    .add_modifier(Modifier::BOLD),
            )),
            DisplayLine::Item { index, section } => {
                let item = &app.items[*index];
                let style = Style::default()
                    .fg(if item.done_at.is_some() {
                        Color::DarkGray
                    } else {
                        colors.foreground
                    })
                    .bg(colors.canvas);
                let cells = columns
                    .iter()
                    .map(|column| column_value(column, item, section, &app.items, app))
                    .collect();
                lines.push(cells_line_selected(
                    cells,
                    &columns,
                    &widths,
                    style,
                    (*index == app.selected).then_some(app.selected_column),
                    Style::default()
                        .fg(colors.selection_foreground)
                        .bg(colors.selection)
                        .add_modifier(Modifier::BOLD),
                ));
                app.item_rows.push((
                    Rect::new(area.x, area.y + shown as u16, area.width, 1),
                    item.id,
                ));
            }
            DisplayLine::Aggregate(indices) => {
                let cells = columns
                    .iter()
                    .map(|column| {
                        if column.percent {
                            String::new()
                        } else {
                            aggregate_value(&column.column, indices, &app.items, app)
                        }
                    })
                    .collect();
                lines.push(cells_line(
                    cells,
                    &widths,
                    Style::default()
                        .fg(colors.canvas_heading)
                        .bg(colors.canvas)
                        .add_modifier(Modifier::BOLD),
                ));
            }
        }
    }
    frame.render_widget(
        Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
        area,
    );
}

enum DisplayLine {
    Section(String),
    Header,
    Item { index: usize, section: Vec<usize> },
    Aggregate(Vec<usize>),
}

#[derive(Clone)]
struct DisplayColumn {
    column: ViewColumn,
    percent: bool,
    base_index: usize,
}

impl DisplayColumn {
    fn heading(&self) -> String {
        if self.percent {
            format!("{} %", self.column.heading)
        } else {
            self.column.heading.clone()
        }
    }

    fn width(&self) -> u16 {
        if self.percent { 10 } else { self.column.width }
    }
}

fn column_widths(columns: &[DisplayColumn], total: u16) -> Vec<usize> {
    let spacing = columns.len().saturating_sub(1) as u16;
    let available = total.saturating_sub(spacing).max(columns.len() as u16);
    let weights = columns
        .iter()
        .map(|column| column.width().max(1) as u32)
        .sum::<u32>()
        .max(1);
    let mut widths = columns
        .iter()
        .map(|column| ((available as u32 * column.width().max(1) as u32 / weights).max(1)) as usize)
        .collect::<Vec<_>>();
    let used = widths.iter().sum::<usize>();
    if let Some(first) = widths.first_mut() {
        *first += available as usize - used;
    }
    widths
}
fn cells_line_selected(
    values: Vec<String>,
    columns: &[DisplayColumn],
    widths: &[usize],
    style: Style,
    selected: Option<usize>,
    selected_style: Style,
) -> Line<'static> {
    let mut spans = Vec::with_capacity(values.len() * 2);
    for (index, ((value, width), column)) in values.into_iter().zip(widths).zip(columns).enumerate()
    {
        if index > 0 {
            spans.push(Span::styled(" ", style));
        }
        spans.push(Span::styled(
            fit_aligned(&value, *width, &column.column.alignment),
            if selected == Some(column.base_index) && !column.percent {
                selected_style
            } else {
                style
            },
        ));
    }
    Line::from(spans)
}
fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> {
    let text = values
        .into_iter()
        .zip(widths)
        .map(|(v, w)| fit(&v, *w))
        .collect::<Vec<_>>()
        .join(" ");
    Line::from(Span::styled(text, style))
}
fn fit(value: &str, width: usize) -> String {
    let mut chars = value.chars();
    let mut out = chars.by_ref().take(width).collect::<String>();
    let count = out.chars().count();
    if chars.next().is_some() && width > 0 {
        out.pop();
        out.push('…')
    } else if count < width {
        out.push_str(&" ".repeat(width - count))
    }
    out
}
fn fit_aligned(value: &str, width: usize, alignment: &str) -> String {
    let fitted = fit(value, width);
    let trimmed = fitted.trim_end();
    let padding = width.saturating_sub(trimmed.chars().count());
    match alignment {
        "right" => format!("{}{trimmed}", " ".repeat(padding)),
        "center" => format!(
            "{}{}{}",
            " ".repeat(padding / 2),
            trimmed,
            " ".repeat(padding - padding / 2)
        ),
        _ => fitted,
    }
}
fn column_value(
    display: &DisplayColumn,
    item: &Item,
    section: &[usize],
    items: &[Item],
    app: &App,
) -> String {
    let column = &display.column;
    if display.percent {
        let total = section
            .iter()
            .filter_map(|index| column.numeric_value(&items[*index]))
            .sum::<f64>();
        return column
            .numeric_value(item)
            .filter(|_| total != 0.0)
            .map(|value| format!("{:.2}%", value * 100.0 / total))
            .unwrap_or_default();
    }
    match column.field.as_str() {
        "item" => {
            let marker = if app.marked.contains(&item.id) {
                "◆"
            } else if item.done_at.is_some() {
                "✓"
            } else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 {
                "◦"
            } else {
                &app.preferences.item_marker
            };
            format!("{marker} {}", item.text.replace('\n', " "))
        }
        "categories" => format_categories(item.category_names()),
        "when" => formatted_when(app, item.when_at.as_deref()),
        "priority" => item.priority.to_string(),
        "note" => item.note.replace(
            '\n',
            if app.preferences.show_carriage_returns {
                "↵ "
            } else {
                " "
            },
        ),
        "value" | "numeric" => column
            .numeric_value(item)
            .map(|value| column.format_number(value))
            .unwrap_or_default(),
        "date" => column
            .category
            .as_deref()
            .and_then(|category| item.date_value_for(category))
            .map(|value| formatted_when(app, Some(value)))
            .unwrap_or_default(),
        "done" => formatted_when(app, item.done_at.as_deref()),
        "alarm" => formatted_when(app, item.alarm_at.as_deref()),
        "recurrence" => item.recurrence.clone(),
        "created" => formatted_when(app, Some(&item.created_at)),
        "updated" => formatted_when(app, Some(&item.updated_at)),
        _ => String::new(),
    }
}
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
    let _ = app;
    column.aggregate_value(indices.iter().map(|index| &items[*index]))
}

fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    let mut lines: Vec<Line> = vec![];
    let mut dates: HashMap<String, bool> = HashMap::new();
    for i in &app.items {
        let formatted = formatted_when(app, i.when_at.as_deref());
        let (date, time) = formatted.rsplit_once(' ').unwrap_or((&formatted, ""));
        let date = date.to_owned();
        if !dates.contains_key(&date) {
            lines.push(Line::from(Span::styled(
                format!("── {date} ──"),
                Style::default()
                    .fg(colors.canvas_heading)
                    .bg(colors.canvas)
                    .add_modifier(Modifier::BOLD),
            )));
            dates.insert(date.clone(), true);
        }
        let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
        let style = if selected {
            Style::default()
                .fg(colors.selection_foreground)
                .bg(colors.selection)
        } else {
            Style::default().fg(colors.foreground).bg(colors.canvas)
        };
        let y = area.y + lines.len() as u16;
        app.item_rows
            .push((Rect::new(area.x, y, area.width, 1), i.id));
        lines.push(Line::from(Span::styled(
            format!("  {}  {}", time, i.text),
            style,
        )));
    }
    frame.render_widget(
        Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
        area,
    );
}

fn format_categories(s: String) -> String {
    if s.is_empty() {
        String::new()
    } else {
        s.split(", ")
            .map(|v| format!("·{v}"))
            .collect::<Vec<_>>()
            .join(" ")
    }
}

fn formatted_when(app: &App, value: Option<&str>) -> String {
    format_when(
        value,
        &app.preferences.date_format,
        app.preferences.clock_24h,
    )
}

fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    let column = app
        .current_view()
        .columns
        .get(app.selected_column)
        .map(|column| format!("  Cell: {}", column.heading))
        .unwrap_or_default();
    let right = format!("{} item(s){column}", app.items.len());
    let pad = area
        .width
        .saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16)
        as usize;
    frame.render_widget(
        Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
            .style(Style::default().fg(colors.accent).bg(colors.primary)),
        area,
    );
}

fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
    let colors = palette(&app.preferences.theme);
    let labels = [
        "Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu",
    ];
    app.command_regions.clear();
    let cells = Layout::default()
        .direction(Direction::Horizontal)
        .constraints((0..10).map(|_| Constraint::Ratio(1, 10)))
        .split(area);
    for (i, r) in cells.iter().enumerate() {
        app.command_regions.push(*r);
        frame.render_widget(
            Paragraph::new(vec![
                Line::from(format!("F{}", i + 1)),
                Line::from(labels[i]),
            ])
            .alignment(Alignment::Center)
            .style(Style::default().fg(Color::White).bg(colors.primary)),
            *r,
        );
    }
}

fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
    let colors = palette(&app.preferences.theme);
    app.choice_regions.clear();
    match &app.mode {
        Mode::Normal => {}
        Mode::Help => {
            let text = Text::from(vec![
                Line::from(Span::styled(
                    "ROGUE AGENDA HELP",
                    Style::default()
                        .fg(colors.accent)
                        .add_modifier(Modifier::BOLD),
                )),
                Line::from(""),
                Line::from(
                    "Capture a sentence first; assign categories and shape views as patterns emerge.",
                ),
                Line::from(""),
                Line::from("↑↓/jk move   Insert/n new   Enter/F2 edit   F3/c categories"),
                Line::from("F4/d done    F5 note       F6/p properties F7/Space mark"),
                Line::from("F8/v views   F9 categories F10/m menu      / search"),
                Line::from("h/l views ([/])   r prerequisites   Delete trash/recover"),
                Line::from("Ctrl-G macros   Ctrl-S save   q quit"),
                Line::from(""),
                Line::from("Mouse: click items, view tabs, or function-key tiles."),
                Line::from("Press any key to close."),
            ]);
            popup(
                frame,
                center(frame.area(), 74, 15),
                "Help",
                Paragraph::new(text).wrap(Wrap { trim: false }),
                colors,
            );
        }
        Mode::Input(input) => {
            let macro_editor = matches!(input.kind, InputKind::MacroEditor(_));
            let h = if macro_editor {
                frame.area().height.saturating_sub(4)
            } else if input.multiline {
                18
            } else {
                7
            };
            let width = if macro_editor {
                frame.area().width.saturating_sub(4)
            } else {
                78
            };
            let area = center(frame.area(), width, h);
            let inner = area.inner(Margin {
                horizontal: 1,
                vertical: 1,
            });
            let before_cursor = &input.value[..input.cursor];
            let cursor_line = before_cursor.bytes().filter(|byte| *byte == b'\n').count();
            let current_line_start = before_cursor.rfind('\n').map_or(0, |index| index + 1);
            let cursor_column = Line::from(&before_cursor[current_line_start..]).width();
            let vertical_scroll =
                cursor_line.saturating_sub(inner.height.saturating_sub(1) as usize);
            let horizontal_scroll =
                cursor_column.saturating_sub(inner.width.saturating_sub(1) as usize);
            let shown = if macro_editor {
                macro_highlight(&input.value)
            } else if input.value.is_empty() {
                Text::from(Line::from(Span::styled(
                    "Type here…",
                    Style::default().fg(Color::DarkGray),
                )))
            } else {
                Text::from(
                    if app.preferences.show_carriage_returns && input.multiline {
                        input.value.replace('\n', "↵\n")
                    } else {
                        input.value.clone()
                    },
                )
            };
            popup(
                frame,
                area,
                &input.title,
                Paragraph::new(shown).scroll((
                    vertical_scroll.min(u16::MAX as usize) as u16,
                    horizontal_scroll.min(u16::MAX as usize) as u16,
                )),
                colors,
            );
            frame.set_cursor_position((
                inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16,
                inner.y + cursor_line.saturating_sub(vertical_scroll) as u16,
            ));
        }
        Mode::Properties(p) => {
            let names = [
                "Priority (1 highest)",
                "When (phrase or RFC3339)",
                "Alarm (phrase or RFC3339)",
                "Recurrence (daily/weekly/monthly/every N days)",
            ];
            let lines = names
                .iter()
                .enumerate()
                .flat_map(|(i, n)| {
                    [
                        Line::from(Span::styled(
                            *n,
                            Style::default().fg(if i == p.field {
                                Color::Yellow
                            } else {
                                colors.accent
                            }),
                        )),
                        Line::from(Span::styled(
                            format!("> {}", p.values[i]),
                            Style::default()
                                .fg(if i == p.field {
                                    colors.selection_foreground
                                } else {
                                    Color::White
                                })
                                .bg(if i == p.field {
                                    colors.selection
                                } else {
                                    colors.primary
                                }),
                        )),
                    ]
                })
                .chain([
                    Line::from(""),
                    Line::from("Tab fields · Enter save · Esc cancel"),
                ])
                .collect::<Vec<_>>();
            popup(
                frame,
                center(frame.area(), 78, 16),
                "Item Properties",
                Paragraph::new(lines),
                colors,
            );
        }
        Mode::Form(form) => {
            let (title, names, hint) = match form.kind {
                FormKind::Category(_) => (
                    "Category Definition",
                    vec![
                        "Name",
                        "Parent category",
                        "Kind",
                        "Match phrases (comma separated)",
                        "Mutually exclusive (yes/no)",
                        "Rule condition",
                        "Rule action",
                    ],
                    "Condition files live; action fires on entry (numeric:Hours=7.5)",
                ),
                FormKind::View(_) => (
                    "Live View Definition",
                    vec![
                        "Name",
                        "Kind",
                        "Filter value",
                        "Boolean filter",
                        "Sort key",
                        "Show done (yes/no)",
                        "Columns (numeric[Hours]:width:heading:aggregate:…)",
                        "Sections (heading|filter; …)",
                    ],
                    "Filters: category=Work and (open or priority<=2)",
                ),
                FormKind::Preferences => (
                    "Preferences  (~/.config/rogue-agenda/preferences.toml)",
                    vec![
                        "Theme",
                        "Show command bar (yes/no)",
                        "Show rule info (yes/no)",
                        "Show return markers (yes/no)",
                        "Item marker (one character)",
                        "Autosave minutes (0-60)",
                        "Confirm destructive actions",
                        "Date format (iso/us/european/long)",
                        "24-hour clock (yes/no)",
                        "Decimal separator",
                        "Thousands separator",
                    ],
                    "Application-wide settings are stored as TOML in your home directory.",
                ),
                FormKind::DocumentSettings => (
                    "Document Settings  (stored inside this .agnd)",
                    vec![
                        "Description",
                        "Backup on open (yes/no)",
                        "Trash (on-demand/on-close/end-of-day/immediate)",
                        "Completed items (keep/trash)",
                        "Automatic filing (yes/no)",
                        "Numeric date order (ymd/mdy/dmy)",
                        "Week starts (monday/sunday)",
                        "Default time (HH:MM)",
                        "Morning time (HH:MM)",
                        "Afternoon time (HH:MM)",
                        "Evening time (HH:MM)",
                        "Note tab width (1-16)",
                        "Report headers and footers (yes/no)",
                    ],
                    "These settings travel with the SQLite document.",
                ),
            };
            let preview = match form.kind {
                FormKind::Category(_) | FormKind::View(_) => {
                    let filter_source = match form.kind {
                        FormKind::Category(_) => form.values.get(5),
                        FormKind::View(_) => form.values.get(3),
                        FormKind::Preferences | FormKind::DocumentSettings => None,
                    }
                    .map(String::as_str)
                    .unwrap_or("");
                    match app.db.count_matching_filter(filter_source) {
                        Ok(n) => format!("Preview: {n} existing item(s) match"),
                        Err(e) => format!("Preview error: {e}"),
                    }
                }
                FormKind::Preferences => format!("File: {}", app.preferences_path.display()),
                FormKind::DocumentSettings => {
                    "Document preferences are saved transactionally".into()
                }
            };
            let lines = names
                .iter()
                .enumerate()
                .map(|(i, name)| {
                    let indicator = if form_choices(&form.kind, i).is_some() {
                        "▾"
                    } else {
                        " "
                    };
                    Line::from(Span::styled(
                        format!("{indicator} {name:<48} {}", form.values[i]),
                        Style::default()
                            .fg(if i == form.field {
                                colors.selection_foreground
                            } else {
                                colors.accent
                            })
                            .bg(if i == form.field {
                                colors.selection
                            } else {
                                colors.primary
                            }),
                    ))
                })
                .chain([
                    Line::from(""),
                    Line::from(hint),
                    Line::from(preview),
                    Line::from("Space opens choices · Tab fields · Enter save · Esc back"),
                ])
                .collect::<Vec<_>>();
            popup(
                frame,
                center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)),
                title,
                Paragraph::new(lines),
                colors,
            );
        }
        Mode::Choice(choice) => {
            let items = choice
                .options
                .iter()
                .map(|option| ListItem::new(option.label.as_str()))
                .collect::<Vec<_>>();
            let mut state = ListState::default().with_selected(Some(choice.selected));
            let area = center(
                frame.area(),
                58,
                (choice.options.len() as u16 + 2).clamp(5, 18),
            );
            let inner = area.inner(Margin {
                horizontal: 1,
                vertical: 1,
            });
            app.choice_regions
                .extend(
                    (0..choice.options.len().min(inner.height as usize)).map(|index| Rect {
                        x: inner.x,
                        y: inner.y + index as u16,
                        width: inner.width,
                        height: 1,
                    }),
                );
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(" Choose  (↑↓ · Enter/Space · Esc) ")
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection)
                            .add_modifier(Modifier::BOLD),
                    ),
                area,
                &mut state,
            );
        }
        Mode::Dependencies {
            item_id,
            choices,
            selected,
        } => {
            let assigned = app.db.dependency_ids(*item_id).unwrap_or_default();
            let items = choices
                .iter()
                .map(|item| {
                    ListItem::new(format!(
                        "{} {}",
                        if assigned.contains(&item.id) {
                            "[×]"
                        } else {
                            "[ ]"
                        },
                        item.text
                    ))
                })
                .collect::<Vec<_>>();
            let mut state = ListState::default().with_selected(Some(*selected));
            let area = center(frame.area(), 76, (choices.len() as u16 + 4).clamp(8, 22));
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(" Prerequisites  (Space toggles · Enter closes) ")
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection)
                            .add_modifier(Modifier::BOLD),
                    ),
                area,
                &mut state,
            );
        }
        Mode::Categories => draw_category_list(
            frame,
            app,
            "Assign Categories  (Space toggles · Enter closes)",
            true,
        ),
        Mode::CategoryManager => draw_category_list(
            frame,
            app,
            "Category Manager  (n add · e edit · Delete discard)",
            false,
        ),
        Mode::Views => {
            let items = app
                .views
                .iter()
                .map(|v| {
                    ListItem::new(format!("{:<18} {:<9} sort: {}", v.name, v.kind, v.sort_key))
                })
                .collect::<Vec<_>>();
            let mut state = ListState::default().with_selected(Some(app.view_selected));
            let area = center(frame.area(), 64, (app.views.len() as u16 + 4).min(20));
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(
                                " View Manager  (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
                            )
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection)
                            .add_modifier(Modifier::BOLD),
                    ),
                area,
                &mut state,
            );
        }
        Mode::Menu => {
            popup(
                frame,
                center(frame.area(), 64, 17),
                "Rogue Agenda Menu",
                Paragraph::new(vec![
                    Line::from(" n  New item"),
                    Line::from(" s  Save / checkpoint"),
                    Line::from(" h  Help"),
                    Line::from(" r  Prerequisites"),
                    Line::from(" p  Preferences (user TOML)"),
                    Line::from(" d  Document settings (.agnd)"),
                    Line::from(" x  Macro Manager (Ctrl-G)"),
                    Line::from(" u  Utilities Execute conditions/actions"),
                    Line::from(" f  Inspect rule conflicts"),
                    Line::from(" b  Back up document now"),
                    Line::from(" t  Empty Trash permanently"),
                    Line::from(" q  Quit"),
                    Line::from(""),
                    Line::from(" Esc closes the menu"),
                ])
                .style(Style::default().fg(Color::White)),
                colors,
            );
        }
        Mode::Execute { selected } => {
            let choices = [
                "Current item",
                "Marked items",
                "Current section",
                "Current view",
                "Whole document",
            ];
            let items = choices
                .iter()
                .map(|choice| ListItem::new(*choice))
                .collect::<Vec<_>>();
            let mut state = ListState::default().with_selected(Some(*selected));
            let area = center(frame.area(), 62, 9);
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(" Utilities Execute ")
                            .title_bottom(" Enter applies conditions/actions · Esc cancels ")
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection),
                    ),
                area,
                &mut state,
            );
        }
        Mode::RuleConflicts => {
            let conflicts = app.db.rule_conflicts().unwrap_or_default();
            let lines = if conflicts.is_empty() {
                vec![Line::from("No rule conflicts.")]
            } else {
                conflicts
                    .iter()
                    .map(|conflict| {
                        Line::from(format!(
                            "#{} {} — {}",
                            conflict.item_id, conflict.item_text, conflict.message
                        ))
                    })
                    .collect()
            };
            popup(
                frame,
                center(frame.area(), 90, (lines.len() as u16 + 4).clamp(7, 22)),
                "Rule Conflicts  (any key closes)",
                Paragraph::new(lines).wrap(Wrap { trim: false }),
                colors,
            );
        }
        Mode::Confirm { prompt, .. } => {
            popup(
                frame,
                center(frame.area(), 66, 7),
                "Confirm",
                Paragraph::new(vec![
                    Line::from(prompt.as_str()),
                    Line::from(""),
                    Line::from("Enter/Y confirms · N/Esc cancels"),
                ]),
                colors,
            );
        }
        Mode::MacroManager => {
            let items = if app.macros.is_empty() {
                vec![ListItem::new("(no macros — press Insert to create one)")]
            } else {
                app.macros
                    .iter()
                    .map(|definition| {
                        ListItem::new(format!(
                            "{:<35} {}",
                            definition.name,
                            if definition.key_binding.is_empty() {
                                String::new()
                            } else {
                                definition.key_binding.clone()
                            }
                        ))
                    })
                    .collect()
            };
            let mut state = ListState::default().with_selected(if app.macros.is_empty() {
                None
            } else {
                Some(app.macro_selected)
            });
            let area = center(
                frame.area(),
                76,
                (app.macros.len() as u16 + 5).clamp(10, 24),
            );
            let inner = area.inner(Margin {
                horizontal: 1,
                vertical: 1,
            });
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(" Macro Manager ")
                            .title_bottom(Line::from(
                                " Ins add  F2 edit  Enter run  F7/r record  a append  F6/b key  Del ",
                            ))
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection)
                            .add_modifier(Modifier::BOLD),
                    ),
                area,
                &mut state,
            );
            let offset = state.offset();
            app.choice_regions
                .extend(std::iter::repeat_n(Rect::default(), offset));
            app.choice_regions.extend(
                (offset..app.macros.len().min(offset + inner.height as usize)).map(|index| {
                    Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1)
                }),
            );
        }
        Mode::MacroMenu {
            title,
            prompt,
            choices,
            selected,
        } => {
            let items = choices
                .iter()
                .map(|choice| ListItem::new(choice.label.as_str()))
                .collect::<Vec<_>>();
            let mut state = ListState::default().with_selected(Some(*selected));
            let area = center(frame.area(), 64, (choices.len() as u16 + 5).clamp(8, 22));
            let inner = area.inner(Margin {
                horizontal: 1,
                vertical: 1,
            });
            frame.render_widget(Clear, area);
            frame.render_stateful_widget(
                List::new(items)
                    .block(
                        Block::bordered()
                            .title(format!(" {title} "))
                            .title_bottom(Line::from(format!(
                                " {prompt} · Enter chooses · Esc cancels "
                            )))
                            .style(Style::default().fg(Color::White).bg(colors.primary)),
                    )
                    .highlight_style(
                        Style::default()
                            .fg(colors.selection_foreground)
                            .bg(colors.selection)
                            .add_modifier(Modifier::BOLD),
                    ),
                area,
                &mut state,
            );
            let offset = state.offset();
            app.choice_regions
                .extend(std::iter::repeat_n(Rect::default(), offset));
            app.choice_regions.extend(
                (offset..choices.len().min(offset + inner.height as usize)).map(|index| {
                    Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1)
                }),
            );
        }
        Mode::MacroMessage { title, message } => popup(
            frame,
            center(frame.area(), 70, 9),
            title,
            Paragraph::new(vec![
                Line::from(message.as_str()),
                Line::from(""),
                Line::from("Press any key to continue the macro"),
            ])
            .wrap(Wrap { trim: false }),
            colors,
        ),
        Mode::MacroGetKey { .. } => popup(
            frame,
            center(frame.area(), 58, 7),
            "Macro Input",
            Paragraph::new("Press a key for the macro, or Esc to cancel"),
            colors,
        ),
    }
}

fn macro_highlight(source: &str) -> Text<'static> {
    let mut lines = Vec::new();
    for line in source.split('\n') {
        let mut spans = Vec::new();
        let mut rest = line;
        while let Some(open) = rest.find('{') {
            if open > 0 {
                spans.push(Span::styled(
                    rest[..open].to_owned(),
                    Style::default().fg(Color::White),
                ));
            }
            let command = &rest[open + 1..];
            let Some(close) = command.find('}') else {
                spans.push(Span::styled(
                    rest[open..].to_owned(),
                    Style::default()
                        .fg(Color::LightRed)
                        .add_modifier(Modifier::BOLD),
                ));
                rest = "";
                break;
            };
            let body = &command[..close];
            let parts = body.split(';').collect::<Vec<_>>();
            let is_comment = parts
                .first()
                .is_some_and(|name| name.eq_ignore_ascii_case("COMMENT"));
            spans.push(Span::styled(
                "{".to_owned(),
                Style::default().fg(Color::DarkGray),
            ));
            for (index, part) in parts.iter().enumerate() {
                if index > 0 {
                    spans.push(Span::styled(
                        ";".to_owned(),
                        Style::default().fg(Color::DarkGray),
                    ));
                }
                let style = if is_comment {
                    Style::default().fg(Color::DarkGray)
                } else if index == 0 {
                    Style::default()
                        .fg(if part.eq_ignore_ascii_case("LABEL") {
                            Color::LightGreen
                        } else {
                            Color::LightCyan
                        })
                        .add_modifier(Modifier::BOLD)
                } else if part.trim_start().starts_with("%%") {
                    Style::default()
                        .fg(Color::LightYellow)
                        .add_modifier(Modifier::BOLD)
                } else if part.trim_start().starts_with('%') {
                    Style::default().fg(Color::Yellow)
                } else if part.trim_start().starts_with('#') {
                    Style::default().fg(Color::LightMagenta)
                } else if part.parse::<f64>().is_ok() {
                    Style::default().fg(Color::LightBlue)
                } else {
                    Style::default().fg(Color::White)
                };
                spans.push(Span::styled((*part).to_owned(), style));
            }
            spans.push(Span::styled(
                "}".to_owned(),
                Style::default().fg(Color::DarkGray),
            ));
            rest = &command[close + 1..];
        }
        if !rest.is_empty() {
            spans.push(Span::styled(
                rest.to_owned(),
                Style::default().fg(Color::White),
            ));
        }
        lines.push(Line::from(spans));
    }
    Text::from(lines)
}

fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
    let colors = palette(&app.preferences.theme);
    let assigned = app
        .selected_item()
        .and_then(|i| app.db.item_category_ids(i.id).ok())
        .unwrap_or_default();
    let excluded = app
        .selected_item()
        .and_then(|i| app.db.excluded_category_ids(i.id).ok())
        .unwrap_or_default();
    let names: HashMap<i64, &str> = app
        .categories
        .iter()
        .map(|c| (c.id, c.name.as_str()))
        .collect();
    let items = app
        .categories
        .iter()
        .map(|c| {
            let indent = if c.parent_id.is_some() { "  └ " } else { "" };
            let marker = if assign && assigned.contains(&c.id) {
                "[×]"
            } else if assign && excluded.contains(&c.id) {
                "[-]"
            } else if assign {
                "[ ]"
            } else {
                " • "
            };
            let parent = c
                .parent_id
                .and_then(|p| names.get(&p))
                .map(|p| format!(" ({p})"))
                .unwrap_or_default();
            let rule = if app.preferences.show_rule_info {
                app.db
                    .category_rules(c.id)
                    .ok()
                    .and_then(|rules| rules.into_iter().next())
                    .map(|r| {
                        format!(
                            " rule: {} → {}:{}",
                            r.condition_expr, r.action_kind, r.action_value
                        )
                    })
                    .unwrap_or_default()
            } else {
                String::new()
            };
            ListItem::new(format!(
                "{marker} {indent}{}{}  {}{}",
                c.name,
                parent,
                if c.match_text.is_empty() {
                    String::new()
                } else {
                    format!("matches: {}", c.match_text)
                },
                rule
            ))
        })
        .collect::<Vec<_>>();
    let mut state = ListState::default().with_selected(Some(app.category_selected));
    let area = center(frame.area(), 78, (app.categories.len() as u16 + 4).min(22));
    frame.render_widget(Clear, area);
    frame.render_stateful_widget(
        List::new(items)
            .block(
                Block::bordered()
                    .title(format!(" {title} "))
                    .style(Style::default().fg(Color::White).bg(colors.primary)),
            )
            .highlight_style(
                Style::default()
                    .fg(colors.selection_foreground)
                    .bg(colors.selection)
                    .add_modifier(Modifier::BOLD),
            ),
        area,
        &mut state,
    );
}

fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>, colors: Palette) {
    frame.render_widget(Clear, area);
    frame.render_widget(
        Block::default()
            .borders(Borders::ALL)
            .title(format!(" {title} "))
            .border_style(Style::default().fg(colors.accent))
            .style(Style::default().bg(colors.primary)),
        area,
    );
    frame.render_widget(
        widget.style(Style::default().fg(Color::White).bg(colors.primary)),
        area.inner(Margin {
            horizontal: 1,
            vertical: 1,
        }),
    );
}
fn center(area: Rect, width: u16, height: u16) -> Rect {
    let w = width.min(area.width.saturating_sub(2));
    let h = height.min(area.height.saturating_sub(2));
    Rect::new(
        area.x + (area.width - w) / 2,
        area.y + (area.height - h) / 2,
        w,
        h,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences};
    use crossterm::event::{
        KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
    };
    use ratatui::{
        Terminal,
        backend::{Backend, TestBackend},
    };
    use tempfile::tempdir;
    #[test]
    fn renders_main_workspace() {
        let d = tempdir().unwrap();
        let p = d.path().join("ui.agnd");
        let mut db = Database::open(&p).unwrap();
        db.add_item("Call Grace tomorrow").unwrap();
        let mut app = App::new_with_preferences(
            db,
            p,
            AppPreferences::default(),
            d.path().join("preferences.toml"),
        )
        .unwrap();
        let backend = TestBackend::new(100, 30);
        let mut term = Terminal::new(backend).unwrap();
        term.draw(|f| draw(f, &mut app)).unwrap();
        let screen = term
            .backend()
            .buffer()
            .content
            .iter()
            .map(|c| c.symbol())
            .collect::<String>();
        assert!(screen.contains("View: All Items"));
        assert!(screen.contains("Call Grace"));
        assert!(screen.contains("Cat Mgr"));
    }

    #[test]
    fn renders_persistent_columns_sections_and_aggregates() {
        let d = tempdir().unwrap();
        let p = d.path().join("designed.agnd");
        let mut db = Database::open(&p).unwrap();
        let item = db.add_item("File the urgent report").unwrap();
        db.update_item(
            item,
            &ItemChanges {
                priority: Some(1),
                numeric_value: Some(Some(75.0)),
                ..Default::default()
            },
        )
        .unwrap();
        let view_id = db
            .save_view_design(
                None,
                "Dashboard",
                "list",
                "",
                "priority",
                true,
                "open",
                "item:70:Action:none,value:30:Cost:sum::2:.:comma:minus:percent:right",
                "Urgent work|priority=1",
            )
            .unwrap();
        let mut app = App::new_with_preferences(
            db,
            p,
            AppPreferences::default(),
            d.path().join("preferences.toml"),
        )
        .unwrap();
        app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
        app.refresh().unwrap();
        let backend = TestBackend::new(90, 24);
        let mut term = Terminal::new(backend).unwrap();
        term.draw(|f| draw(f, &mut app)).unwrap();
        let screen = term
            .backend()
            .buffer()
            .content
            .iter()
            .map(|c| c.symbol())
            .collect::<String>();
        assert!(screen.contains("Urgent work"));
        assert!(screen.contains("Action"));
        assert!(screen.contains("Cost"));
        assert!(screen.contains("Total 75.00"));
        assert!(screen.contains("100.00%"));
    }

    #[test]
    fn compact_layout_keeps_primary_fields_visible() {
        let d = tempdir().unwrap();
        let p = d.path().join("compact.agnd");
        let mut db = Database::open(&p).unwrap();
        db.add_item("Compact item tomorrow").unwrap();
        let mut app = App::new_with_preferences(
            db,
            p,
            AppPreferences::default(),
            d.path().join("preferences.toml"),
        )
        .unwrap();
        let backend = TestBackend::new(50, 15);
        let mut term = Terminal::new(backend).unwrap();
        term.draw(|f| draw(f, &mut app)).unwrap();
        let screen = term
            .backend()
            .buffer()
            .content
            .iter()
            .map(|c| c.symbol())
            .collect::<String>();
        assert!(screen.contains("Compact item"));
        assert!(screen.contains("When"));
    }

    #[test]
    fn main_screen_h_and_l_cycle_views_with_wraparound() {
        let directory = tempdir().unwrap();
        let path = directory.path().join("view-navigation.agnd");
        let db = Database::open(&path).unwrap();
        let mut app = App::new_with_preferences(
            db,
            path,
            AppPreferences::default(),
            directory.path().join("preferences.toml"),
        )
        .unwrap();
        assert!(app.views.len() > 1);

        app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_index, 1);
        app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_index, 0);
        app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_index, app.views.len() - 1);
        app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_index, 0);
    }

    #[test]
    fn note_editor_renders_and_saves_entered_line_breaks() {
        let d = tempdir().unwrap();
        let p = d.path().join("notes.agnd");
        let mut db = Database::open(&p).unwrap();
        db.add_item("Write release notes").unwrap();
        let mut app = App::new_with_preferences(
            db,
            p,
            AppPreferences::default(),
            d.path().join("preferences.toml"),
        )
        .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
            .unwrap();
        for c in "first line".chars() {
            app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
                .unwrap();
        }
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();
        for c in "second line".chars() {
            app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
                .unwrap();
        }

        let backend = TestBackend::new(100, 30);
        let mut term = Terminal::new(backend).unwrap();
        term.draw(|frame| draw(frame, &mut app)).unwrap();
        let cursor = term.backend_mut().get_cursor_position().unwrap();
        let rows = term
            .backend()
            .buffer()
            .content
            .chunks(100)
            .map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
            .collect::<Vec<_>>();
        let first_row = rows
            .iter()
            .position(|row| row.contains("first line"))
            .unwrap();
        let second_row = rows
            .iter()
            .position(|row| row.contains("second line"))
            .unwrap();
        assert_eq!(second_row, first_row + 1);
        assert_eq!(cursor.y as usize, second_row);

        app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
            .unwrap();
        assert_eq!(app.items[0].note, "first line\nsecond line");
    }

    #[test]
    fn application_preferences_change_the_workspace_and_open_from_the_menu() {
        let directory = tempdir().unwrap();
        let path = directory.path().join("preferences-ui.agnd");
        let mut db = Database::open(&path).unwrap();
        let item = db.add_item("Configured display item").unwrap();
        db.update_item(
            item,
            &ItemChanges {
                when_at: Some(Some("2026-08-17T15:30:00+00:00".into())),
                numeric_value: Some(Some(1234.5)),
                ..Default::default()
            },
        )
        .unwrap();
        let preferences = AppPreferences {
            theme: "amber".into(),
            show_command_bar: false,
            item_marker: ">".into(),
            date_format: "us".into(),
            clock_24h: false,
            decimal_separator: ",".into(),
            thousands_separator: ".".into(),
            ..Default::default()
        };
        let mut app = App::new_with_preferences(
            db,
            path,
            preferences,
            directory.path().join("preferences.toml"),
        )
        .unwrap();
        let backend = TestBackend::new(100, 30);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| draw(frame, &mut app)).unwrap();
        let screen = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect::<String>();
        assert!(screen.contains("> Configured display item"));
        assert!(screen.contains("08/17/2026 3:30pm"));
        assert!(!screen.contains("Cat Mgr"));

        app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
            .unwrap();
        assert!(matches!(
            app.mode,
            Mode::Form(crate::app::FormState {
                kind: FormKind::Preferences,
                ..
            })
        ));
    }

    #[test]
    fn predefined_preferences_open_choice_popovers_for_keyboard_and_mouse() {
        let directory = tempdir().unwrap();
        let path = directory.path().join("choice-popovers.agnd");
        let db = Database::open(&path).unwrap();
        let mut app = App::new_with_preferences(
            db,
            path,
            AppPreferences::default(),
            directory.path().join("preferences.toml"),
        )
        .unwrap();

        app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .unwrap();
        assert!(matches!(app.mode, Mode::Choice(_)));

        let backend = TestBackend::new(100, 30);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| draw(frame, &mut app)).unwrap();
        let screen = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect::<String>();
        assert!(screen.contains("Classic — Lotus-inspired blue"));
        assert!(screen.contains("Amber — warm dark"));
        assert!(screen.contains("Greenscreen — muted phosphor green"));
        assert!(screen.contains("Nord — arctic blue dark"));
        assert!(screen.contains("Catppuccin Mocha — cozy pastel dark"));

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .unwrap();
        let Mode::Form(form) = &app.mode else {
            panic!("choice selection did not return to the form");
        };
        assert_eq!(form.values[0], "mono");

        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .unwrap();
        terminal.draw(|frame| draw(frame, &mut app)).unwrap();
        let amber_region = app.choice_regions[2];
        app.handle_mouse(MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column: amber_region.x,
            row: amber_region.y,
            modifiers: KeyModifiers::NONE,
        })
        .unwrap();
        let Mode::Form(form) = &app.mode else {
            panic!("mouse choice did not return to the form");
        };
        assert_eq!(form.values[0], "amber");

        assert_eq!(palette("greenscreen").foreground, Color::Rgb(143, 189, 128));
        assert_eq!(palette("nord").primary, Color::Rgb(46, 52, 64));
        assert_eq!(
            palette("catppuccin-mocha").foreground,
            Color::Rgb(205, 214, 244)
        );

        let trash_choices = form_choices(&FormKind::DocumentSettings, 2).unwrap();
        assert_eq!(
            trash_choices
                .iter()
                .map(|choice| choice.value.as_str())
                .collect::<Vec<_>>(),
            ["on-demand", "on-close", "end-of-day", "immediate"]
        );
    }

    #[test]
    fn additional_themes_keep_selected_rows_at_high_contrast() {
        for theme in ["greenscreen", "nord", "catppuccin-mocha"] {
            let colors = palette(theme);
            let ratio = contrast_ratio(colors.selection_foreground, colors.selection);
            assert!(
                ratio >= 7.0,
                "{theme} selected-row contrast was only {ratio:.2}:1"
            );
        }
    }

    #[test]
    fn every_theme_keeps_canvas_headings_readable() {
        for theme in [
            "classic",
            "mono",
            "amber",
            "greenscreen",
            "nord",
            "catppuccin-mocha",
        ] {
            let colors = palette(theme);
            let ratio = contrast_ratio(colors.canvas_heading, colors.canvas);
            assert!(
                ratio >= 4.5,
                "{theme} canvas-heading contrast was only {ratio:.2}:1"
            );
        }
    }

    #[test]
    fn macro_source_highlighting_distinguishes_language_elements() {
        let highlighted = macro_highlight(
            "{demo}\n{defstr;%local;%%global}\n{type;#date}\n{comment;ignored}\n{broken",
        );
        assert_eq!(
            highlighted.lines[0].spans[1].style.fg,
            Some(Color::LightCyan)
        );
        assert_eq!(highlighted.lines[1].spans[3].style.fg, Some(Color::Yellow));
        assert_eq!(
            highlighted.lines[1].spans[5].style.fg,
            Some(Color::LightYellow)
        );
        assert_eq!(
            highlighted.lines[2].spans[3].style.fg,
            Some(Color::LightMagenta)
        );
        assert_eq!(
            highlighted.lines[3].spans[1].style.fg,
            Some(Color::DarkGray)
        );
        assert_eq!(
            highlighted.lines[4].spans[0].style.fg,
            Some(Color::LightRed)
        );
    }

    fn contrast_ratio(first: Color, second: Color) -> f64 {
        let first = relative_luminance(first);
        let second = relative_luminance(second);
        (first.max(second) + 0.05) / (first.min(second) + 0.05)
    }

    fn relative_luminance(color: Color) -> f64 {
        let (red, green, blue) = match color {
            Color::Black => (0, 0, 0),
            Color::White => (255, 255, 255),
            Color::Gray => (128, 128, 128),
            Color::Rgb(red, green, blue) => (red, green, blue),
            _ => panic!("contrast test requires RGB, black, white, or gray"),
        };
        let linear = |component: u8| {
            let component = f64::from(component) / 255.0;
            if component <= 0.04045 {
                component / 12.92
            } else {
                ((component + 0.055) / 1.055).powf(2.4)
            }
        };
        0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
    }

    #[test]
    fn document_editor_and_confirmation_use_saved_settings() {
        let directory = tempdir().unwrap();
        let path = directory.path().join("document-ui.agnd");
        let mut db = Database::open(&path).unwrap();
        let mut settings = db.document_settings().unwrap();
        settings.note_tab_width = 3;
        settings.trash_policy = crate::model::TrashPolicy::Immediate;
        db.save_document_settings(&settings).unwrap();
        db.add_item("Disposable item").unwrap();
        let mut app = App::new_with_preferences(
            db,
            path,
            AppPreferences::default(),
            directory.path().join("preferences.toml"),
        )
        .unwrap();

        app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
            .unwrap();
        assert_eq!(app.items[0].note, "   x");

        app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
            .unwrap();
        assert!(matches!(app.mode, Mode::Confirm { .. }));
        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.items.len(), 1);
        app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();
        assert!(app.items.is_empty());
    }
}
