use ratatui_core::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    terminal::Frame,
    text::{Line, Span},
};
use ratatui_widgets::{
    block::Block,
    borders::Borders,
    clear::Clear,
    gauge::Gauge,
    list::{List, ListItem, ListState},
    paragraph::{Paragraph, Wrap},
};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::app::{
    format_elapsed, format_modified_time, human, size_sort_key, App, Focus, InputMode, PkgView,
    SortMode,
};
use crate::bulkstat::SizeInfo;
use crate::packages::{DepEvidence, PackageUseStatus};
use crate::reclaim::Reclaimability;

pub fn draw(f: &mut Frame, app: &mut App) {
    let root = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(5),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(f.area());

    draw_header(f, root[0], app);

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
        .split(root[1]);

    let packages_visible =
        app.focus == Focus::Packages || app.packages_loaded || app.packages_loading;
    let (disk_height, package_height) =
        side_panel_heights(body[1].height, app.disks.len(), packages_visible);

    let side = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(disk_height),
            Constraint::Length(package_height),
        ])
        .split(body[1]);

    draw_files(f, body[0], app);
    draw_disks(f, side[0], app);
    draw_packages(f, side[1], app);

    draw_status(f, root[2], app);
    draw_help(f, root[3]);

    if app.focus == Focus::Reclaim && !app.reclaim_paths_open() {
        draw_reclaim_panel(f, app);
    }
    if app.top_files_open() {
        draw_top_files(f, app);
    }
    if app.reclaim_paths_open() {
        draw_reclaim_paths(f, app);
    }
    if app.disk_info_open() {
        draw_disk_info_modal(f, app);
    }

    if app.confirming_delete {
        draw_confirm(f, app);
    }
    if app.confirming_uninstall {
        draw_uninstall_confirm(f, app);
    }
    if app.pkg_detail {
        draw_pkg_detail(f, app);
    }
    if app.input_mode != InputMode::None {
        draw_input_overlay(f, app);
    }
}

fn draw_input_overlay(f: &mut Frame, app: &App) {
    let area = centered_rect(50, 20, 40, 6, f.area());
    let block = Block::default()
        .title(app.input_prompt.as_str())
        .borders(Borders::ALL);
    let text = vec![Line::from(""), Line::from(app.input_buffer.as_str())];
    let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
    f.render_widget(Clear, area);
    f.render_widget(paragraph, area);
}

fn draw_header(f: &mut Frame, area: Rect, app: &App) {
    let path = truncate_start(
        &app.cwd.display().to_string(),
        area.width.saturating_sub(30) as usize,
    );
    let mut spans = vec![
        Span::styled("diskr", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(" · "),
        Span::styled(path, Style::default().fg(Color::Cyan)),
        Span::raw(" · "),
        Span::styled(
            format!("sort {}", app.sort.label()),
            Style::default().fg(Color::Gray),
        ),
        Span::raw(" · "),
        Span::styled(
            format!("hidden {}", if app.show_hidden { "on" } else { "off" }),
            Style::default().fg(Color::Gray),
        ),
    ];
    if let Some(baseline) = app.history_baseline_status() {
        spans.push(Span::styled(
            format!(" · {baseline}"),
            Style::default().fg(Color::DarkGray),
        ));
    }
    if let Some(delta) = app.history_delta_status() {
        spans.push(Span::styled(
            format!(" · {delta}"),
            Style::default().fg(Color::Green),
        ));
    }
    let text = Line::from(spans);
    f.render_widget(Paragraph::new(text), area);
}

fn draw_files(f: &mut Frame, area: Rect, app: &mut App) {
    app.files_area = area;
    let border_color = if app.focus == Focus::Files {
        Color::Yellow
    } else {
        Color::DarkGray
    };
    let show_modified = app.sort == SortMode::Modified;
    let (name_width, size_width, modified_width, bar_width) =
        file_columns(area.width.saturating_sub(2), show_modified);
    let block = Block::default()
        .borders(Borders::ALL)
        .title(format!("files ({})", app.entries.len()))
        .border_style(Style::default().fg(border_color));
    let visible_count = app.visible_entry_count();
    if visible_count == 0 {
        let message = if app.entries.is_empty() {
            "empty directory"
        } else {
            "no matching entries"
        };
        let empty = Paragraph::new(message)
            .block(block)
            .alignment(Alignment::Center);
        f.render_widget(empty, area);
        return;
    }
    let max_rows = area.height.saturating_sub(2).max(1) as usize;
    let (offset, end) =
        file_window_bounds(app.selected, visible_count, app.file_list_offset, max_rows);
    app.file_list_offset = offset;
    let visible_entries: Vec<&crate::app::Entry> = (offset..end)
        .filter_map(|visible_index| app.visible_entry(visible_index))
        .collect();
    let max_visible_size = visible_entries
        .iter()
        .filter_map(|entry| entry.size.map(size_sort_key))
        .max()
        .unwrap_or(0);
    let total_visible_size: u64 = visible_entries
        .iter()
        .filter_map(|entry| entry.size.map(size_sort_key))
        .sum();

    let items: Vec<ListItem> = visible_entries
        .into_iter()
        .map(|e| {
            let (size_str, size_style) = match (e.is_dir, e.size, e.scanning) {
                (true, _, true) => (
                    format!("{} scanning…", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ),
                (true, Some(size), _) => (
                    size_with_markers(size_sort_key(size), e.inaccessible, e.size_stale),
                    if e.size_stale {
                        Style::default().fg(Color::DarkGray)
                    } else {
                        Style::default().fg(Color::Green)
                    },
                ),
                (true, None, _) => (String::from("—"), Style::default().fg(Color::DarkGray)),
                (false, Some(size), _) if e.is_symlink => (
                    human(size_sort_key(size)),
                    Style::default().fg(Color::Green),
                ),
                (false, None, _) if e.is_symlink => {
                    (String::from("link"), Style::default().fg(Color::DarkGray))
                }
                (false, Some(size), _) => (
                    human(size_sort_key(size)),
                    Style::default().fg(Color::Green),
                ),
                (false, None, _) => (String::from("?"), Style::default().fg(Color::DarkGray)),
            };
            let icon = if e.is_dir {
                "▸ "
            } else if e.is_symlink {
                "↪ "
            } else {
                "  "
            };
            let name_style = if e.is_dir {
                Style::default()
                    .fg(Color::Blue)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            let mut spans = vec![
                Span::styled(icon, Style::default().fg(Color::DarkGray)),
                Span::styled(
                    format!(
                        "{:<width$}",
                        truncate(&e.name, name_width),
                        width = name_width
                    ),
                    name_style,
                ),
            ];
            let (bar_text, bar_percent, bar_style) = file_size_bar(
                e.size.map(size_sort_key),
                max_visible_size,
                total_visible_size,
                bar_width,
            );
            if size_width > 0 {
                spans.push(Span::raw("  "));
                spans.push(Span::styled(format!("{size_str:>size_width$}"), size_style));
            }
            if bar_width > 0 && !e.scanning {
                spans.push(Span::raw("  "));
                spans.push(Span::styled(bar_text, bar_style));
                spans.push(Span::raw(" "));
                spans.push(Span::styled(
                    bar_percent,
                    Style::default().fg(Color::DarkGray),
                ));
            }
            if show_modified && modified_width > 0 {
                let modified = format_modified_time(e.modified);
                spans.push(Span::raw("  "));
                spans.push(Span::styled(
                    format!("{modified:>modified_width$}"),
                    Style::default().fg(Color::DarkGray),
                ));
            }
            let line = Line::from(spans);
            ListItem::new(line)
        })
        .collect();

    let list = List::new(items)
        .block(block)
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("▶ ");

    let mut state = ListState::default();
    state.select(Some(app.selected.saturating_sub(offset)));
    f.render_stateful_widget(list, area, &mut state);
}

fn file_window_bounds(
    selected: usize,
    total_items: usize,
    current_offset: usize,
    max_rows: usize,
) -> (usize, usize) {
    if total_items == 0 {
        return (0, 0);
    }

    let max_rows = max_rows.max(1);
    let max_offset = total_items.saturating_sub(max_rows);
    let mut offset = current_offset.min(max_offset);
    let selected = selected.min(total_items.saturating_sub(1));

    if selected < offset {
        offset = selected;
    } else if selected >= offset + max_rows {
        offset = selected + 1 - max_rows;
    }

    let end = (offset + max_rows).min(total_items);
    (offset, end)
}

fn draw_disks(f: &mut Frame, area: Rect, app: &App) {
    if area.height == 0 || area.width == 0 {
        return;
    }
    let border_color = if app.focus == Focus::Disks {
        Color::Yellow
    } else {
        Color::DarkGray
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .title(format!("disks ({})", app.disks.len()))
        .border_style(Style::default().fg(border_color));
    let inner = block.inner(area);
    f.render_widget(block, area);

    if app.disks.is_empty() {
        f.render_widget(
            Paragraph::new("no disks detected").alignment(Alignment::Center),
            inner,
        );
        return;
    }
    if inner.height < 4 {
        let selected = app
            .disks
            .get(app.selected_disk)
            .map(disk_label)
            .unwrap_or_else(|| String::from("no disks"));
        f.render_widget(
            Paragraph::new(truncate(&selected, inner.width as usize)).alignment(Alignment::Center),
            inner,
        );
        return;
    }
    let max_rows = (inner.height / 4).max(1) as usize;
    let (offset, end) = disk_window_bounds(app.selected_disk, app.disks.len(), max_rows);
    let visible_disks = &app.disks[offset..end];
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints(
            visible_disks
                .iter()
                .map(|_| Constraint::Length(4))
                .collect::<Vec<_>>(),
        )
        .split(inner);

    for (visible_i, d) in visible_disks.iter().enumerate() {
        if visible_i >= rows.len() {
            break;
        }
        let disk_index = offset + visible_i;
        let selected = app.focus == Focus::Disks && disk_index == app.selected_disk;
        let used = d.total.saturating_sub(d.available);
        let pct = if d.total > 0 {
            (used as f64 / d.total as f64 * 100.0) as u16
        } else {
            0
        };
        let disk_name = disk_label(d);
        let label = if selected {
            format!("> {}  {} / {}", disk_name, human(used), human(d.total))
        } else {
            format!("{}  {} / {}", disk_name, human(used), human(d.total))
        };
        let color = if pct > 90 {
            Color::Red
        } else if pct > 75 {
            Color::Yellow
        } else {
            Color::Green
        };
        let mut gauge_style = Style::default().fg(color);
        if selected {
            gauge_style = gauge_style.add_modifier(Modifier::BOLD);
        }
        let gauge = Gauge::default()
            .block(Block::default().title(label))
            .gauge_style(gauge_style)
            .percent(pct.min(100));
        f.render_widget(gauge, rows[visible_i]);
    }
}

fn draw_reclaim_panel(f: &mut Frame, app: &App) {
    let area = centered_rect(70, 70, 72, 22, f.area());
    f.render_widget(Clear, area);

    if app.reclaim_loading {
        let inner = Block::default().borders(Borders::ALL).title(" reclaim ");
        let body = Paragraph::new(vec![
            Line::from(""),
            Line::from(vec![
                Span::styled(
                    format!("{} ", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled(
                    "scanning reclaim recommendations…",
                    Style::default().fg(Color::White),
                ),
            ]),
            Line::from(""),
            Line::from("press Enter on a finding to view recoverable paths"),
        ])
        .block(inner)
        .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let report = match app.reclaim_report.as_ref() {
        Some(report) => report,
        None => {
            let inner = Block::default().borders(Borders::ALL).title(" reclaim ");
            let body = Paragraph::new("press R to scan reclaim recommendations")
                .block(inner)
                .alignment(Alignment::Center);
            f.render_widget(body, area);
            return;
        }
    };

    let item_count = report.findings.len();
    if item_count == 0 {
        let inner = Block::default().borders(Borders::ALL).title(" reclaim ");
        let body = Paragraph::new("no reclaim candidates found")
            .block(inner)
            .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let items: Vec<ListItem> = report
        .findings
        .iter()
        .map(|finding| {
            let class_style = match finding.class {
                Reclaimability::Safe => Style::default().fg(Color::Green),
                Reclaimability::Regenerable => Style::default().fg(Color::Yellow),
                Reclaimability::Risky => Style::default().fg(Color::Red),
            };
            let mut spans = Vec::new();
            let size = if finding.size.allocated == finding.size.logical {
                format!("{} ", human(finding.size.allocated))
            } else {
                format!(
                    "{} / {} ",
                    human(finding.size.allocated),
                    human(finding.size.logical)
                )
            };
            let size = if finding.inaccessible > 0 {
                format!("≥{size}")
            } else {
                size
            };
            spans.push(Span::styled(
                format!("{size:>16}"),
                Style::default().fg(Color::Green),
            ));
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                format!("{:>11}", finding.class.label()),
                class_style,
            ));
            spans.push(Span::raw("  "));
            spans.push(Span::styled(
                truncate(&finding.label, area.width.saturating_sub(52) as usize),
                Style::default().fg(Color::White),
            ));
            ListItem::new(Line::from(spans))
        })
        .collect();

    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(format!(
                    " reclaim {} · {} ",
                    finding_count_label(item_count),
                    human(report.total.allocated)
                ))
                .title_style(Style::default().fg(Color::DarkGray)),
        )
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("▶ ");

    let mut state = ListState::default();
    state.select(Some(app.selected_reclaim.min(item_count.saturating_sub(1))));
    f.render_stateful_widget(list, area, &mut state);

    if let Some(finding) = app.selected_reclaim_finding() {
        let detail_area = centered_rect(70, 28, 72, 9, f.area());
        f.render_widget(Clear, detail_area);
        let size_line = format!(
            "{} · {} paths",
            if finding.size.allocated == finding.size.logical {
                human(finding.size.allocated)
            } else {
                format!(
                    "{} / {}",
                    human(finding.size.allocated),
                    human(finding.size.logical)
                )
            },
            finding.count
        );
        let mut lines = vec![
            Line::from(""),
            Line::from(Span::styled(size_line, Style::default().fg(Color::Green))),
            Line::from(Span::styled(
                finding.note.clone(),
                Style::default().fg(Color::Gray),
            )),
        ];
        if finding.inaccessible > 0 {
            lines.push(Line::from(Span::styled(
                format!(
                    "{} unreadable directories; size is a lower bound",
                    finding.inaccessible
                ),
                Style::default().fg(Color::Yellow),
            )));
        }
        lines.push(Line::from(""));
        lines.push(Line::from(
            "enter: open paths  ·  d: delete path  ·  esc: close paths",
        ));
        let block = Block::default()
            .borders(Borders::ALL)
            .title(" reclaim finding ")
            .border_style(Style::default().fg(Color::DarkGray));
        f.render_widget(Paragraph::new(lines).block(block), detail_area);
    }
}

fn finding_count_label(count: usize) -> String {
    if count == 1 {
        String::from("1 finding")
    } else {
        format!("{count} findings")
    }
}

fn draw_reclaim_paths(f: &mut Frame, app: &mut App) {
    let area = centered_rect(68, 68, 72, 20, f.area());
    f.render_widget(Clear, area);

    let item_count = app.reclaim_paths_count();
    if item_count == 0 {
        let block = Block::default()
            .borders(Borders::ALL)
            .title(" reclaim paths ");
        let body = Paragraph::new("no reclaim paths found")
            .block(block)
            .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(3), Constraint::Length(2)])
        .split(area);
    let max_rows = chunks[0].height.saturating_sub(2).max(1) as usize;
    let (offset, end) = app.reclaim_paths_window_bounds(max_rows);
    let selected = app.reclaim_paths_selected();

    let finding = match app.selected_reclaim_finding() {
        Some(finding) => finding,
        None => return,
    };
    let items: Vec<ListItem> = finding
        .paths
        .iter()
        .enumerate()
        .skip(offset)
        .take(end.saturating_sub(offset))
        .map(|(idx, path)| {
            let mut spans = vec![
                Span::styled(
                    format!("{:>2}. ", idx + 1),
                    Style::default().fg(Color::DarkGray),
                ),
                Span::styled(
                    truncate(
                        &path.display().to_string(),
                        chunks[0].width.saturating_sub(8) as usize,
                    ),
                    Style::default().fg(Color::White),
                ),
            ];
            if idx == selected {
                spans.push(Span::styled(
                    "  (current)",
                    Style::default().fg(Color::DarkGray),
                ));
            }
            ListItem::new(Line::from(spans))
        })
        .collect();

    let list = List::new(items)
        .block(Block::default().borders(Borders::ALL).title(format!(
            " {} ({}) · {} ",
            finding.label,
            finding.class.label(),
            finding.count
        )))
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("▶ ");
    let mut state = ListState::default();
    state.select(Some(selected.saturating_sub(offset)));
    f.render_stateful_widget(list, chunks[0], &mut state);

    let footer = Paragraph::new(vec![
        Line::from(format!("{}-{} of {} paths", offset + 1, end, item_count)),
        Line::from("f/enter: reveal  ·  d: trash  ·  esc: close"),
    ]);
    f.render_widget(footer, chunks[1]);
}

fn draw_top_files(f: &mut Frame, app: &mut App) {
    let area = centered_rect(70, 70, 74, 20, f.area());
    f.render_widget(Clear, area);

    if app.top_files_loading() {
        let block = Block::default().borders(Borders::ALL).title(" top files ");
        let mut lines = vec![
            Line::from(""),
            Line::from(vec![
                Span::styled(
                    format!("{} ", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled("scanning top files…", Style::default().fg(Color::White)),
            ]),
        ];
        if let Some(path) = app.top_files_path() {
            lines.push(Line::from(format!("path: {}", path.display())));
        }
        let body = Paragraph::new(lines)
            .block(block)
            .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let file_count = app.top_files_count();
    if file_count == 0 {
        let message = if app.top_files_scan().is_some() {
            "no regular files found"
        } else {
            "no scan in progress"
        };
        let block = Block::default().borders(Borders::ALL).title(" top files ");
        let body = Paragraph::new(message)
            .block(block)
            .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(3), Constraint::Length(2)])
        .split(area);
    let max_rows = chunks[0].height.saturating_sub(2).max(1) as usize;
    let (offset, end) = app.top_files_window_bounds(max_rows);
    let selected = app.top_files_selected();

    let title = app
        .top_files_path()
        .map(|path| format!(" top files · {} ", path.display()))
        .unwrap_or_else(|| String::from(" top files "));

    let scan = match app.top_files_scan() {
        Some(scan) => scan,
        None => return,
    };
    let items: Vec<ListItem> = scan
        .largest_files
        .iter()
        .enumerate()
        .skip(offset)
        .take(end.saturating_sub(offset))
        .map(|(idx, file)| {
            let mut spans = vec![
                Span::styled(
                    format!("{:>2}. ", idx + 1),
                    Style::default().fg(Color::DarkGray),
                ),
                Span::styled(
                    truncate(
                        &file.path.display().to_string(),
                        chunks[0].width.saturating_sub(34) as usize,
                    ),
                    Style::default().fg(Color::White),
                ),
            ];
            spans.push(Span::raw("  "));
            let size = if file.size.allocated == file.size.logical {
                human(file.size.allocated)
            } else {
                format!(
                    "{} / {}",
                    human(file.size.allocated),
                    human(file.size.logical)
                )
            };
            spans.push(Span::styled(
                format!("{size:>20}"),
                Style::default().fg(Color::Green),
            ));
            ListItem::new(Line::from(spans))
        })
        .collect();

    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(title)
                .title_style(Style::default().fg(Color::DarkGray)),
        )
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("▶ ");
    let mut state = ListState::default();
    state.select(Some(selected.saturating_sub(offset)));
    f.render_stateful_widget(list, chunks[0], &mut state);

    let total = if scan.size.allocated == scan.size.logical {
        human(scan.size.allocated)
    } else {
        format!(
            "{} / {}",
            human(scan.size.allocated),
            human(scan.size.logical)
        )
    };
    let footer = vec![
        Line::from(vec![
            Span::styled(format!("total: {total}"), Style::default().fg(Color::Green)),
            Span::styled(
                format!(" · {}-{} of {}", offset + 1, end, file_count),
                Style::default().fg(Color::DarkGray),
            ),
        ]),
        Line::from("f/enter: reveal  ·  d: trash  ·  esc: close"),
    ];
    f.render_widget(Paragraph::new(footer), chunks[1]);
}

fn draw_disk_info_modal(f: &mut Frame, app: &App) {
    let area = centered_rect(65, 62, 60, 18, f.area());
    f.render_widget(Clear, area);

    if app.disk_info_loading() {
        let block = Block::default().borders(Borders::ALL).title(" disk info ");
        let body = Paragraph::new(vec![
            Line::from(""),
            Line::from(vec![
                Span::styled(
                    format!("{} ", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled("loading disk details…", Style::default().fg(Color::White)),
            ]),
        ])
        .block(block)
        .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    }

    let Some(report) = app.disk_info_report.as_ref() else {
        let block = Block::default().borders(Borders::ALL).title(" disk info ");
        let body = Paragraph::new("no disk details available")
            .block(block)
            .alignment(Alignment::Center);
        f.render_widget(body, area);
        return;
    };

    let used = report.used;
    let gap = report.unavailable_free();
    let mut lines = Vec::new();
    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled("mount ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            format!(
                "{}  ({}, {})",
                report.mount.display(),
                report.fs_type,
                report.device
            ),
            Style::default().fg(Color::White),
        ),
    ]));
    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled("total ", Style::default().fg(Color::DarkGray)),
        Span::styled(human(report.total), Style::default().fg(Color::Green)),
    ]));
    lines.push(Line::from(vec![
        Span::styled("used ", Style::default().fg(Color::DarkGray)),
        Span::styled(human(used), Style::default().fg(Color::Green)),
    ]));
    lines.push(Line::from(vec![
        Span::styled("free ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            format!(
                "{}  (available: {})",
                human(report.free),
                human(report.available)
            ),
            Style::default().fg(Color::Green),
        ),
    ]));
    if gap > 0 {
        lines.push(Line::from(vec![
            Span::styled("free-not-available ", Style::default().fg(Color::DarkGray)),
            Span::styled(human(gap), Style::default().fg(Color::Yellow)),
        ]));
    }
    lines.push(Line::from(""));
    if let Some(container) = &report.apfs_container {
        lines.push(Line::from(vec![
            Span::styled("apfs container ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} free of {}",
                    human(container.free),
                    human(container.size)
                ),
                Style::default().fg(Color::Green),
            ),
        ]));
    }
    match report.local_snapshots.error.as_deref() {
        Some(err) => {
            lines.push(Line::from(vec![
                Span::styled("snapshots ", Style::default().fg(Color::DarkGray)),
                Span::styled(err, Style::default().fg(Color::Red)),
            ]));
        }
        _ => {
            let names = &report.local_snapshots.names;
            if names.is_empty() {
                lines.push(Line::from(Span::styled(
                    "snapshots: none",
                    Style::default().fg(Color::DarkGray),
                )));
            } else {
                lines.push(Line::from(Span::styled(
                    format!("snapshots: {}", names.len()),
                    Style::default().fg(Color::DarkGray),
                )));
                for name in names.iter().take(6) {
                    lines.push(Line::from(Span::styled(
                        truncate(name, area.width.saturating_sub(6) as usize),
                        Style::default().fg(Color::Gray),
                    )));
                }
            }
        }
    }
    lines.push(Line::from(""));
    lines.push(Line::from("esc closes this panel"));

    let block = Block::default()
        .borders(Borders::ALL)
        .title(" disk details ");
    f.render_widget(Paragraph::new(lines).block(block), area);
}

fn draw_packages(f: &mut Frame, area: Rect, app: &App) {
    if area.height == 0 || area.width == 0 {
        return;
    }
    let border_color = if app.focus == Focus::Packages {
        Color::Yellow
    } else {
        Color::DarkGray
    };
    let (sys_style, proj_style) = if app.pkg_view == PkgView::SystemManagers {
        (
            Style::default()
                .add_modifier(Modifier::BOLD)
                .fg(Color::Yellow),
            Style::default().fg(Color::DarkGray),
        )
    } else {
        (
            Style::default().fg(Color::DarkGray),
            Style::default()
                .add_modifier(Modifier::BOLD)
                .fg(Color::Yellow),
        )
    };

    let total_str = if app.packages_loaded {
        match app.pkg_view {
            PkgView::SystemManagers => {
                let total = app.total_pkg_size();
                if total > 0 {
                    format!(" · {}", human(total))
                } else {
                    String::new()
                }
            }
            PkgView::ProjectDeps => {
                let total = app.total_project_deps_size();
                if total > 0 {
                    format!(" · {}", human(total))
                } else {
                    String::new()
                }
            }
        }
    } else {
        String::new()
    };

    let title = Line::from(vec![
        Span::raw(" packages ─"),
        Span::styled(
            if app.pkg_view == PkgView::SystemManagers {
                "[ System ]"
            } else {
                " System "
            },
            sys_style,
        ),
        Span::raw("─"),
        Span::styled(
            if app.pkg_view == PkgView::ProjectDeps {
                "[ Projects ]"
            } else {
                " Projects "
            },
            proj_style,
        ),
        Span::styled(total_str, Style::default().fg(Color::Green)),
        if app.pkg_show_unused {
            Span::styled(" [dependency leaves]", Style::default().fg(Color::Magenta))
        } else {
            Span::raw("")
        },
    ]);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(border_color));

    if !app.packages_loaded {
        let block_inner = block.inner(area);
        f.render_widget(block, area);

        if app.packages_loading {
            if block_inner.height >= 6 {
                let chunks = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([
                        Constraint::Min(1),
                        Constraint::Length(1), // Title
                        Constraint::Length(1), // Activity bar
                        Constraint::Length(1), // Subtitle
                        Constraint::Min(1),
                    ])
                    .split(block_inner);

                let title = Line::from(vec![
                    Span::styled(
                        format!("{} ", big_spinner_char()),
                        Style::default().fg(Color::Cyan),
                    ),
                    Span::styled("scanning package data…", Style::default().fg(Color::White)),
                ]);
                f.render_widget(
                    Paragraph::new(title).alignment(Alignment::Center),
                    chunks[1],
                );

                let act_bar = activity_bar(block_inner.width as usize);
                let bar_line =
                    Line::from(Span::styled(act_bar, Style::default().fg(Color::DarkGray)));
                f.render_widget(
                    Paragraph::new(bar_line).alignment(Alignment::Center),
                    chunks[2],
                );

                let sub = Line::from(Span::styled(
                    "checking package managers and project dependencies",
                    Style::default().fg(Color::DarkGray),
                ));
                f.render_widget(Paragraph::new(sub).alignment(Alignment::Center), chunks[3]);
            } else {
                let message = Line::from(vec![
                    Span::styled(
                        format!("{} ", spinner_char()),
                        Style::default().fg(Color::Cyan),
                    ),
                    Span::styled("scanning…", Style::default().fg(Color::White)),
                ]);
                let text = Paragraph::new(message)
                    .alignment(Alignment::Center)
                    .wrap(Wrap { trim: true });
                f.render_widget(text, block_inner);
            }
        } else {
            let message = "press p to scan packages";
            let text = Paragraph::new(message)
                .alignment(Alignment::Center)
                .wrap(Wrap { trim: true });
            f.render_widget(text, block_inner);
        }
        return;
    }

    let item_count = app.pkg_item_count();
    let inner_width = area.width.saturating_sub(2);

    if item_count == 0 {
        let message = if app.pkg_search_mode && !app.pkg_search_query.is_empty() {
            "no matching packages"
        } else {
            match app.pkg_view {
                PkgView::SystemManagers => "no supported packages found",
                PkgView::ProjectDeps => "no project manifests found",
            }
        };
        let empty = Paragraph::new(message)
            .block(block)
            .alignment(Alignment::Center)
            .wrap(Wrap { trim: true });
        f.render_widget(empty, area);
        return;
    }

    let items: Vec<ListItem> = (0..item_count)
        .filter_map(|visible_i| {
            let real_i = app.pkg_visible_index(visible_i)?;
            match app.pkg_view {
                PkgView::SystemManagers => {
                    let (package, manager) = app.flat_packages().get(real_i)?;
                    let size = package
                        .size
                        .map(|s| human(s.allocated))
                        .unwrap_or_else(|| String::from("?"));
                    let use_status = app
                        .dep_graph
                        .as_ref()
                        .map(|g| g.use_status(*manager, &package.name))
                        .unwrap_or(PackageUseStatus::Untracked);
                    Some(package_line_with_version(
                        manager.label(),
                        &package.name,
                        &package.version,
                        &size,
                        use_status,
                        inner_width,
                    ))
                }
                PkgView::ProjectDeps => {
                    let dep = app.project_deps.get(real_i)?;
                    let size = dep
                        .deps_size
                        .map(|s| human(s.allocated))
                        .unwrap_or_else(|| String::from("—"));
                    let label = format!(
                        "{} · {} deps · {}",
                        dep.manager_label,
                        dep.dep_count,
                        dep.path.display()
                    );
                    let (name_width, size_width) = package_columns(inner_width);
                    Some(package_line(&label, &size, name_width, size_width))
                }
            }
        })
        .collect();

    let list = List::new(items)
        .block(block)
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("▶ ");

    let mut state = ListState::default();
    state.select(Some(app.selected_pkg.min(item_count.saturating_sub(1))));
    f.render_stateful_widget(list, area, &mut state);
}

fn package_line_with_version(
    manager: &str,
    name: &str,
    version: &str,
    size: &str,
    use_status: PackageUseStatus,
    inner_width: u16,
) -> ListItem<'static> {
    let (name_width, size_width) = package_columns(inner_width);
    let ver_display = if version.is_empty() {
        String::new()
    } else {
        format!("@{version}")
    };
    let dep_indicator = match use_status {
        PackageUseStatus::RequiredByDependents => "* ",
        PackageUseStatus::DependencyLeaf => "  ",
        PackageUseStatus::Untracked => "? ",
    };
    let mgr_label = format!("{manager} ");
    let name_budget = name_width
        .saturating_sub(mgr_label.len())
        .saturating_sub(dep_indicator.len());
    let name_ver = format!("{name}{ver_display}");
    let name_truncated = truncate(&name_ver, name_budget);
    let padded_name = format!("{name_truncated:<width$}", width = name_budget);

    let dep_style = match use_status {
        PackageUseStatus::Untracked => Style::default().fg(Color::Yellow),
        _ => Style::default().fg(Color::DarkGray),
    };
    let mut spans = vec![
        Span::styled(dep_indicator, dep_style),
        Span::styled(mgr_label, Style::default().fg(Color::DarkGray)),
        Span::styled(padded_name, Style::default().fg(Color::White)),
    ];
    if size_width > 0 {
        spans.push(Span::raw("  "));
        spans.push(Span::styled(
            format!("{size:>size_width$}"),
            Style::default().fg(Color::Green),
        ));
    }
    ListItem::new(Line::from(spans))
}

fn package_line(
    label: &str,
    size: &str,
    name_width: usize,
    size_width: usize,
) -> ListItem<'static> {
    let mut spans = vec![Span::styled(
        format!(
            "{:<width$}",
            truncate(label, name_width),
            width = name_width
        ),
        Style::default().fg(Color::White),
    )];
    if size_width > 0 {
        spans.push(Span::raw("  "));
        spans.push(Span::styled(
            format!("{size:>size_width$}"),
            Style::default().fg(Color::Green),
        ));
    }
    ListItem::new(Line::from(spans))
}

fn draw_status(f: &mut Frame, area: Rect, app: &App) {
    if app.search_mode {
        let query = if app.search_query.is_empty() {
            String::from("/")
        } else {
            format!("/{}", app.search_query)
        };
        let count = app.visible_entry_count();
        let text = Line::from(vec![
            Span::styled("search ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                query,
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!(" · {count} matches · Enter keep · Esc clear"),
                Style::default().fg(Color::Gray),
            ),
        ]);
        f.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area);
        return;
    }
    if app.pkg_search_mode {
        let query = if app.pkg_search_query.is_empty() {
            String::from("/")
        } else {
            format!("/{}", app.pkg_search_query)
        };
        let count = app.pkg_item_count();
        let text = Line::from(vec![
            Span::styled("filter packages ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                query,
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!(" · {count} matches · Enter keep · Esc clear"),
                Style::default().fg(Color::Gray),
            ),
        ]);
        f.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area);
        return;
    }
    let mut spans = selection_status(app);
    if !app.status.is_empty() {
        spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
        spans.push(Span::styled(&app.status, Style::default().fg(Color::Gray)));
    }
    let text = Line::from(spans);
    f.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area);
}

fn draw_help(f: &mut Frame, area: Rect) {
    let key = |k: &'static str| {
        Span::styled(
            k,
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        )
    };
    let label = |s: &'static str| Span::styled(s, Style::default().fg(Color::Gray));
    let sep = || Span::styled(" · ", Style::default().fg(Color::DarkGray));

    let text = Line::from(vec![
        key("↑↓/jk"),
        label(" move"),
        sep(),
        key("Pg/Home"),
        label(" jump"),
        sep(),
        key("←→/hl"),
        label(" pane/view"),
        sep(),
        key("/"),
        label(" search"),
        sep(),
        key("⏎"),
        label(" open"),
        sep(),
        key("⌫"),
        label(" up"),
        sep(),
        key("Space"),
        label(" preview"),
        sep(),
        key("f"),
        label(" finder"),
        sep(),
        key("O"),
        label(" open"),
        sep(),
        key("y"),
        label(" copy"),
        sep(),
        key("s"),
        label(" terminal"),
        sep(),
        key("r"),
        label(" refresh"),
        sep(),
        key("S"),
        label(" scan all"),
        sep(),
        key("o"),
        label(" sort"),
        sep(),
        key("p"),
        label(" packages"),
        sep(),
        key("."),
        label(" hidden"),
        sep(),
        key("d"),
        label(" trash"),
        sep(),
        key("i"),
        label(" info"),
        sep(),
        key("u"),
        label(" leaves"),
        sep(),
        key("x"),
        label(" uninstall"),
        sep(),
        key("Tab"),
        label(" pane"),
        sep(),
        key("q"),
        label(" quit"),
    ]);
    f.render_widget(Paragraph::new(text), area);
}

fn draw_confirm(f: &mut Frame, app: &App) {
    let area = centered_rect(60, 20, 40, 6, f.area());
    f.render_widget(Clear, area);
    let name = app.pending_delete_name();
    let body = vec![
        Line::from(""),
        Line::from(Span::styled(
            format!("Move to Trash: {name}"),
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from("press  y  to confirm   ·   n  to cancel"),
    ];
    let block = Block::default()
        .borders(Borders::ALL)
        .title("confirm")
        .border_style(Style::default().fg(Color::Red));
    f.render_widget(
        Paragraph::new(body)
            .block(block)
            .alignment(Alignment::Center),
        area,
    );
}

fn draw_uninstall_confirm(f: &mut Frame, app: &App) {
    let area = centered_rect(60, 20, 44, 6, f.area());
    f.render_widget(Clear, area);
    let name = app.pending_uninstall_name();
    let body = vec![
        Line::from(""),
        Line::from(Span::styled(
            format!("Uninstall: {name}"),
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from("press  y  to confirm   ·   n  to cancel"),
    ];
    let block = Block::default()
        .borders(Borders::ALL)
        .title("uninstall")
        .border_style(Style::default().fg(Color::Red));
    f.render_widget(
        Paragraph::new(body)
            .block(block)
            .alignment(Alignment::Center),
        area,
    );
}

fn draw_pkg_detail(f: &mut Frame, app: &App) {
    let Some((pkg, manager, dep_info)) = app.selected_pkg_detail() else {
        return;
    };

    let area = centered_rect(75, 60, 50, 14, f.area());
    f.render_widget(Clear, area);

    let mut lines = Vec::new();
    lines.push(Line::from(""));

    lines.push(Line::from(vec![
        Span::styled(
            format!("{} ", manager.label()),
            Style::default().fg(Color::DarkGray),
        ),
        Span::styled(
            &pkg.name,
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
        if pkg.version.is_empty() {
            Span::raw("")
        } else {
            Span::styled(
                format!("@{}", pkg.version),
                Style::default().fg(Color::Gray),
            )
        },
    ]));

    if let Some(size) = pkg.size {
        lines.push(Line::from(vec![
            Span::styled("  Size: ", Style::default().fg(Color::DarkGray)),
            Span::styled(size_detail(size), Style::default().fg(Color::Green)),
        ]));
    }
    if let Some(path) = &pkg.path {
        let max_path_len = area.width.saturating_sub(10) as usize;
        lines.push(Line::from(vec![
            Span::styled("  Path: ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                truncate(&path.display().to_string(), max_path_len),
                Style::default().fg(Color::Gray),
            ),
        ]));
    }

    lines.push(Line::from(""));

    match dep_info {
        Some(info) if info.evidence == DepEvidence::ManagerGraph => {
            if manager.is_global_leaf_manager()
                && info.dependencies.is_empty()
                && info.dependents.is_empty()
            {
                lines.push(Line::from(Span::styled(
                    "  Usage: global install - no package-manager dependents",
                    Style::default().fg(Color::Green),
                )));
                lines.push(Line::from(Span::styled(
                    "  Treat as a leaf unless you use it manually or from scripts.",
                    Style::default().fg(Color::Gray),
                )));
            } else {
                let dep_text = if info.dependencies.is_empty() {
                    String::from("none")
                } else {
                    let max_len = area.width.saturating_sub(18) as usize;
                    let joined = info.dependencies.join(", ");
                    truncate(&joined, max_len)
                };
                lines.push(Line::from(vec![
                    Span::styled(
                        format!("  Dependencies ({}): ", info.dependencies.len()),
                        Style::default().fg(Color::DarkGray),
                    ),
                    Span::styled(dep_text, Style::default().fg(Color::White)),
                ]));

                let rev_text = if info.dependents.is_empty() {
                    String::from("none in manager graph")
                } else {
                    let max_len = area.width.saturating_sub(14) as usize;
                    let joined = info.dependents.join(", ");
                    truncate(&joined, max_len)
                };
                let rev_style = if info.dependents.is_empty() {
                    Style::default().fg(Color::Green)
                } else {
                    Style::default().fg(Color::Yellow)
                };
                lines.push(Line::from(vec![
                    Span::styled(
                        format!("  Used by ({}): ", info.dependents.len()),
                        Style::default().fg(Color::DarkGray),
                    ),
                    Span::styled(rev_text, rev_style),
                ]));
            }
        }
        Some(_) => {
            lines.push(Line::from(Span::styled(
                "  Usage: not dependency-tracked by this package manager",
                Style::default().fg(Color::Yellow),
            )));
            lines.push(Line::from(Span::styled(
                "  This can still be a runtime, app, environment, CLI, or manually used tool.",
                Style::default().fg(Color::Gray),
            )));
        }
        None => {
            if app.deps_loading {
                lines.push(Line::from(vec![
                    Span::styled(
                        format!("  {} ", spinner_char()),
                        Style::default().fg(Color::Cyan),
                    ),
                    Span::styled(
                        "scanning dependency tree…",
                        Style::default().fg(Color::Gray),
                    ),
                ]));
            } else {
                lines.push(Line::from(Span::styled(
                    "  dependency info not available",
                    Style::default().fg(Color::DarkGray),
                )));
            }
        }
    }

    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled(
            "  x",
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(" uninstall", Style::default().fg(Color::Gray)),
        Span::styled("  ·  ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            "d",
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(" trash dir", Style::default().fg(Color::Gray)),
        Span::styled("  ·  ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            "Esc",
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(" close", Style::default().fg(Color::Gray)),
    ]));

    let block = Block::default()
        .borders(Borders::ALL)
        .title(" package info ")
        .border_style(Style::default().fg(Color::Cyan));
    f.render_widget(Paragraph::new(lines).block(block), area);
}

fn centered_rect(px: u16, py: u16, min_width: u16, min_height: u16, area: Rect) -> Rect {
    let width = area
        .width
        .saturating_mul(px)
        .saturating_div(100)
        .max(min_width)
        .min(area.width);
    let height = area
        .height
        .saturating_mul(py)
        .saturating_div(100)
        .max(min_height)
        .min(area.height);
    let x = area.x + area.width.saturating_sub(width) / 2;
    let y = area.y + area.height.saturating_sub(height) / 2;
    Rect::new(x, y, width, height)
}

fn truncate(s: &str, max: usize) -> String {
    if max == 0 {
        return String::new();
    }
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
        out.push('…');
        out
    }
}

fn truncate_start(s: &str, max: usize) -> String {
    if max == 0 {
        return String::new();
    }
    let len = s.chars().count();
    if len <= max {
        return s.to_string();
    }
    if max == 1 {
        return String::from("…");
    }
    let tail: String = s.chars().skip(len.saturating_sub(max - 1)).collect();
    format!("…{tail}")
}

fn selection_status(app: &App) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    match app.focus {
        Focus::Files => match app.visible_entry(app.selected) {
            Some(entry) if entry.is_dir && entry.scanning => {
                spans.push(Span::styled(
                    format!("{} ", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ));
                spans.push(Span::styled("dir ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(&entry.name, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(
                    " · scanning size",
                    Style::default().fg(Color::Gray),
                ));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    format_modified_time(entry.modified),
                    Style::default().fg(Color::Gray),
                ));
            }
            Some(entry) if entry.is_dir => {
                spans.push(Span::styled("dir ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(&entry.name, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                let size = entry
                    .size
                    .map(|size| size_detail_with_cache_marker(size, entry.size_stale))
                    .unwrap_or_else(|| String::from("—"));
                let size_style = if entry.size_stale {
                    Style::default().fg(Color::DarkGray)
                } else {
                    Style::default().fg(Color::Green)
                };
                spans.push(Span::styled(size, size_style));
                if let Some(status) = stale_cache_status(entry) {
                    spans.push(Span::styled(
                        format!(" · {status}"),
                        Style::default().fg(Color::DarkGray),
                    ));
                }
                if entry.inaccessible > 0 {
                    spans.push(Span::styled(
                        format!(
                            " · {} unreadable dirs; size is a lower bound",
                            entry.inaccessible
                        ),
                        Style::default().fg(Color::Yellow),
                    ));
                }
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    format_modified_time(entry.modified),
                    Style::default().fg(Color::Gray),
                ));
            }
            Some(entry) if entry.is_symlink => {
                spans.push(Span::styled("link ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(&entry.name, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                let size = entry
                    .size
                    .map(size_detail)
                    .unwrap_or_else(|| String::from("symlink"));
                spans.push(Span::styled(size, Style::default().fg(Color::Green)));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    format_modified_time(entry.modified),
                    Style::default().fg(Color::Gray),
                ));
            }
            Some(entry) => {
                spans.push(Span::styled("file ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(&entry.name, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                let size = entry
                    .size
                    .map(size_detail)
                    .unwrap_or_else(|| String::from("?"));
                spans.push(Span::styled(size, Style::default().fg(Color::Green)));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    format_modified_time(entry.modified),
                    Style::default().fg(Color::Gray),
                ));
            }
            None => {
                spans.push(Span::styled(
                    "no files in view",
                    Style::default().fg(Color::Gray),
                ));
            }
        },
        Focus::Disks => match app.disks.get(app.selected_disk) {
            Some(disk) => {
                let free = human(disk.available);
                let total = human(disk.total);
                let label = if disk.name.is_empty() {
                    disk.mount.display().to_string()
                } else {
                    disk.name.clone()
                };
                spans.push(Span::styled("disk ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(&label, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(
                    format!(" · {free} free of {total}"),
                    Style::default().fg(Color::Gray),
                ));
            }
            None => {
                spans.push(Span::styled(
                    "no disks available",
                    Style::default().fg(Color::Gray),
                ));
            }
        },
        Focus::Reclaim => {
            if let Some((name, path)) = app.selected_reclaim_path() {
                spans.push(Span::styled(
                    "reclaim ",
                    Style::default().fg(Color::DarkGray),
                ));
                spans.push(Span::styled(
                    truncate(&name, 28),
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    truncate(path.to_string_lossy().as_ref(), 34),
                    Style::default().fg(Color::Gray),
                ));
            } else {
                spans.push(Span::styled(
                    "no reclaim path selected",
                    Style::default().fg(Color::Gray),
                ));
            }
        }
        Focus::Packages => {
            if app.packages_loading {
                spans.push(Span::styled(
                    format!("{} ", spinner_char()),
                    Style::default().fg(Color::Cyan),
                ));
                spans.push(Span::styled(
                    "scanning packages",
                    Style::default().fg(Color::White),
                ));
                return spans;
            }
            if !app.packages_loaded {
                spans.push(Span::styled(
                    "packages not scanned",
                    Style::default().fg(Color::Gray),
                ));
                return spans;
            }
            match app.pkg_view {
                PkgView::SystemManagers => {
                    let packages = app.flat_packages();
                    let real_idx = app
                        .pkg_visible_index(app.selected_pkg)
                        .unwrap_or(usize::MAX);
                    match packages.get(real_idx) {
                        Some((package, manager)) => {
                            let size = package
                                .size
                                .map(size_detail)
                                .unwrap_or_else(|| String::from("?"));
                            spans.push(Span::styled(
                                format!("{} package ", manager.label()),
                                Style::default().fg(Color::DarkGray),
                            ));
                            spans.push(Span::styled(
                                truncate(&package.name, 28),
                                Style::default()
                                    .fg(Color::White)
                                    .add_modifier(Modifier::BOLD),
                            ));
                            spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
                            spans.push(Span::styled(size, Style::default().fg(Color::Green)));
                        }
                        None => {
                            spans.push(Span::styled(
                                "no packages in view",
                                Style::default().fg(Color::Gray),
                            ));
                        }
                    }
                }
                PkgView::ProjectDeps => match app
                    .pkg_visible_index(app.selected_pkg)
                    .and_then(|i| app.project_deps.get(i))
                {
                    Some(dep) => {
                        let size = dep
                            .deps_size
                            .map(size_detail)
                            .unwrap_or_else(|| String::from("—"));
                        spans.push(Span::styled(
                            format!("{} project · {} deps · ", dep.manager_label, dep.dep_count),
                            Style::default().fg(Color::Gray),
                        ));
                        spans.push(Span::styled(size, Style::default().fg(Color::Green)));
                    }
                    None => {
                        spans.push(Span::styled(
                            "no project dependencies in view",
                            Style::default().fg(Color::Gray),
                        ));
                    }
                },
            }
        }
    }
    spans
}

fn side_panel_heights(side_height: u16, disk_count: usize, packages_visible: bool) -> (u16, u16) {
    if side_height == 0 {
        return (0, 0);
    }
    if side_height <= 8 {
        return (side_height, 0);
    }

    let desired_disk_height = if disk_count == 0 {
        3
    } else {
        (disk_count as u16).saturating_mul(4).saturating_add(2)
    };
    let min_package_height = if packages_visible { 7 } else { 5 };
    let reserved_package_height = min_package_height.min(side_height.saturating_sub(6));
    let max_disk_height = side_height.saturating_sub(reserved_package_height);
    let disk_height = desired_disk_height.min(max_disk_height).max(3);
    (disk_height, side_height.saturating_sub(disk_height))
}

fn disk_window_bounds(selected: usize, total_items: usize, max_rows: usize) -> (usize, usize) {
    if total_items == 0 {
        return (0, 0);
    }
    let max_rows = max_rows.max(1);
    let selected = selected.min(total_items.saturating_sub(1));
    let half = max_rows / 2;
    let max_offset = total_items.saturating_sub(max_rows);
    let offset = selected.saturating_sub(half).min(max_offset);
    let end = (offset + max_rows).min(total_items);
    (offset, end)
}

fn disk_label(disk: &crate::app::DiskInfo) -> String {
    if disk.name.is_empty() {
        disk.mount.display().to_string()
    } else {
        format!("{}  {}", disk.name, disk.mount.display())
    }
}

fn size_detail(size: SizeInfo) -> String {
    if size.allocated == size.logical {
        return human(size.logical);
    }
    format!(
        "{} disk · {} apparent",
        human(size.allocated),
        human(size.logical)
    )
}

fn size_detail_with_cache_marker(size: SizeInfo, stale: bool) -> String {
    let detail = size_detail(size);
    if stale {
        format!("~{detail}")
    } else {
        detail
    }
}

fn size_with_markers(bytes: u64, inaccessible: u32, stale: bool) -> String {
    let mut label = String::new();
    if stale {
        label.push('~');
    }
    if inaccessible > 0 {
        label.push('≥');
    }
    label.push_str(&human(bytes));
    label
}

fn stale_cache_status(entry: &crate::app::Entry) -> Option<String> {
    if !entry.size_stale {
        return None;
    }
    let scanned_at = entry.cached_at?;
    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
    let age = now.saturating_sub(scanned_at);
    Some(format!(
        "cached {}; press r to refresh",
        format_elapsed(age)
    ))
}

fn file_columns(inner_width: u16, show_modified: bool) -> (usize, usize, usize, usize) {
    const ICON_WIDTH: usize = 2;
    const GAP_WIDTH: usize = 2;
    const MIN_NAME_WIDTH: usize = 8;
    const MIN_SIZE_WIDTH: usize = 4;
    const PREFERRED_SIZE_WIDTH: usize = 12;
    const MIN_MODIFIED_WIDTH: usize = 4;
    const PREFERRED_MODIFIED_WIDTH: usize = 8;
    const MIN_BAR_WIDTH: usize = 8;
    const MAX_BAR_WIDTH: usize = 14;
    const PREFERRED_BAR_WIDTH: usize = 10;
    const BAR_THRESHOLD_WIDTH: usize = 55;

    let inner_width = inner_width as usize;
    let content_width = inner_width.saturating_sub(ICON_WIDTH);
    let show_bar = inner_width >= BAR_THRESHOLD_WIDTH;
    let preferred_bar_width = if show_bar {
        PREFERRED_BAR_WIDTH.clamp(MIN_BAR_WIDTH, MAX_BAR_WIDTH)
    } else {
        0
    };

    if !show_modified {
        let bare_name_width = content_width.max(1);
        let room_for_size = content_width.saturating_sub(MIN_NAME_WIDTH);
        if room_for_size < MIN_SIZE_WIDTH {
            return (bare_name_width, 0, 0, 0);
        }

        if preferred_bar_width > 0 {
            let minimum_width =
                MIN_NAME_WIDTH + GAP_WIDTH + MIN_SIZE_WIDTH + GAP_WIDTH + MIN_BAR_WIDTH;
            if room_for_size >= minimum_width.saturating_sub(MIN_NAME_WIDTH) {
                let mut size_width = PREFERRED_SIZE_WIDTH.min(room_for_size);
                let mut bar_width = preferred_bar_width;
                let mut required = MIN_NAME_WIDTH + GAP_WIDTH + size_width + GAP_WIDTH + bar_width;
                if required > content_width {
                    let mut overflow = required - content_width;
                    let shrink_bar = (bar_width.saturating_sub(MIN_BAR_WIDTH)).min(overflow);
                    bar_width -= shrink_bar;
                    overflow -= shrink_bar;
                    size_width = size_width.saturating_sub(overflow).max(MIN_SIZE_WIDTH);
                    required = MIN_NAME_WIDTH + GAP_WIDTH + size_width + GAP_WIDTH + bar_width;
                }
                if required <= content_width {
                    let name_width = content_width
                        .saturating_sub(GAP_WIDTH + size_width + GAP_WIDTH + bar_width)
                        .max(1);
                    return (name_width, size_width, 0, bar_width);
                }
            }
        }

        let size_width = room_for_size.min(PREFERRED_SIZE_WIDTH);
        let name_width = content_width.saturating_sub(GAP_WIDTH + size_width).max(1);
        return (name_width, size_width, 0, 0);
    }

    let room_for_name_and_meta = content_width;
    let min_for_modified_only = MIN_NAME_WIDTH + GAP_WIDTH + MIN_MODIFIED_WIDTH;
    if room_for_name_and_meta < min_for_modified_only {
        return (content_width.max(1), 0, 0, 0);
    }

    let min_for_size_and_modified =
        MIN_NAME_WIDTH + GAP_WIDTH + MIN_SIZE_WIDTH + GAP_WIDTH + MIN_MODIFIED_WIDTH;
    let mut size_width;
    let mut modified_width;
    if room_for_name_and_meta >= min_for_size_and_modified && inner_width >= 50 {
        size_width = PREFERRED_SIZE_WIDTH;
        modified_width = PREFERRED_MODIFIED_WIDTH;
        let mut required = MIN_NAME_WIDTH + GAP_WIDTH + size_width + GAP_WIDTH + modified_width;
        if required > room_for_name_and_meta {
            let mut overflow = required - room_for_name_and_meta;
            let shrink_modified = modified_width
                .saturating_sub(MIN_MODIFIED_WIDTH)
                .min(overflow);
            modified_width -= shrink_modified;
            overflow -= shrink_modified;
            size_width = size_width.saturating_sub(overflow).max(MIN_SIZE_WIDTH);
            required = MIN_NAME_WIDTH + GAP_WIDTH + size_width + GAP_WIDTH + modified_width;
        }
        if required <= room_for_name_and_meta {
            let name_width = content_width
                .saturating_sub(GAP_WIDTH + size_width + GAP_WIDTH + modified_width)
                .max(1);
            if preferred_bar_width == 0 {
                return (name_width, size_width, modified_width, 0);
            }

            let mut bar_width = preferred_bar_width;
            let mut required = MIN_NAME_WIDTH
                + GAP_WIDTH
                + size_width
                + GAP_WIDTH
                + modified_width
                + GAP_WIDTH
                + bar_width;
            if required > room_for_name_and_meta {
                let mut overflow = required - room_for_name_and_meta;
                let shrink_bar = (bar_width.saturating_sub(MIN_BAR_WIDTH)).min(overflow);
                bar_width -= shrink_bar;
                overflow -= shrink_bar;
                let shrink_modified =
                    (modified_width.saturating_sub(MIN_MODIFIED_WIDTH)).min(overflow);
                modified_width -= shrink_modified;
                overflow -= shrink_modified;
                size_width = size_width.saturating_sub(overflow).max(MIN_SIZE_WIDTH);
                required = MIN_NAME_WIDTH
                    + GAP_WIDTH
                    + size_width
                    + GAP_WIDTH
                    + modified_width
                    + GAP_WIDTH
                    + bar_width;
            }

            if required <= room_for_name_and_meta {
                let name_width = content_width
                    .saturating_sub(
                        GAP_WIDTH + size_width + GAP_WIDTH + modified_width + GAP_WIDTH + bar_width,
                    )
                    .max(1);
                return (name_width, size_width, modified_width, bar_width);
            }
        }
    }

    let modified_width = room_for_name_and_meta
        .saturating_sub(MIN_NAME_WIDTH + GAP_WIDTH)
        .clamp(MIN_MODIFIED_WIDTH, PREFERRED_MODIFIED_WIDTH);
    let name_width = content_width
        .saturating_sub(GAP_WIDTH + modified_width)
        .max(1);
    let bar_width = if preferred_bar_width == 0 {
        0
    } else {
        let mut bar_width = preferred_bar_width;
        let mut required = MIN_NAME_WIDTH + GAP_WIDTH + modified_width + GAP_WIDTH + bar_width;
        if required > room_for_name_and_meta {
            let overflow = required - room_for_name_and_meta;
            bar_width = bar_width.saturating_sub(overflow).max(MIN_BAR_WIDTH);
            required = MIN_NAME_WIDTH + GAP_WIDTH + modified_width + GAP_WIDTH + bar_width;
        }
        if required <= room_for_name_and_meta {
            bar_width
        } else {
            0
        }
    };
    if bar_width == 0 {
        return (name_width, 0, modified_width, 0);
    }
    (name_width, 0, modified_width, bar_width)
}

fn package_columns(inner_width: u16) -> (usize, usize) {
    const GAP_WIDTH: usize = 2;
    const MIN_NAME_WIDTH: usize = 10;
    const MIN_SIZE_WIDTH: usize = 4;
    const PREFERRED_SIZE_WIDTH: usize = 9;

    let inner_width = inner_width as usize;
    let room_for_size = inner_width.saturating_sub(GAP_WIDTH + MIN_NAME_WIDTH);
    if room_for_size < MIN_SIZE_WIDTH {
        return (inner_width.max(1), 0);
    }

    let size_width = room_for_size.min(PREFERRED_SIZE_WIDTH);
    let name_width = inner_width.saturating_sub(GAP_WIDTH + size_width).max(1);
    (name_width, size_width)
}

fn file_size_bar(
    size: Option<u64>,
    max_visible_size: u64,
    total_visible_size: u64,
    bar_width: usize,
) -> (String, String, Style) {
    if bar_width == 0 {
        return (String::new(), String::new(), Style::default());
    }

    let Some(size) = size else {
        let blank = format!("{: <bar_width$}", " ");
        return (
            blank,
            String::from(" --%"),
            Style::default().fg(Color::DarkGray),
        );
    };

    if max_visible_size == 0 {
        let blank = format!("{: <bar_width$}", " ");
        return (
            blank,
            format!("{:>3}%", 0),
            Style::default().fg(Color::DarkGray),
        );
    }

    let percent_of_max = if size >= max_visible_size {
        100
    } else {
        rounded_percent(size, max_visible_size)
    };
    let percent_of_sum = rounded_percent(size, total_visible_size);
    let filled = if size == 0 {
        0
    } else {
        let numerator = size.saturating_mul(u64::try_from(bar_width).unwrap_or(0));
        if max_visible_size == 0 || numerator == 0 {
            0
        } else {
            numerator
                .saturating_div(max_visible_size)
                .max(1)
                .min(u64::try_from(bar_width).unwrap_or(u64::MAX))
        }
    };
    let filled = usize::try_from(filled).unwrap_or(0).min(bar_width);

    let style = if percent_of_max >= 50 {
        Style::default().fg(Color::Red)
    } else if percent_of_max >= 25 {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::White)
    };
    let bar_text = format!(
        "{}{}",
        "█".repeat(filled),
        "░".repeat(bar_width.saturating_sub(filled)),
    );
    (bar_text, format!("{percent_of_sum:>3}%"), style)
}

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

    #[test]
    fn truncate_preserves_short_names() {
        assert_eq!(truncate("Downloads", 40), "Downloads");
    }

    #[test]
    fn truncate_keeps_unicode_boundary() {
        assert_eq!(truncate("résumé.txt", 7), "résumé…");
    }

    #[test]
    fn truncate_start_keeps_path_tail() {
        assert_eq!(truncate_start("/Users/milo/Downloads", 10), "…Downloads");
    }

    #[test]
    fn centered_rect_uses_expected_percent_area() {
        let area = Rect::new(0, 0, 100, 40);
        let got = centered_rect(60, 20, 40, 6, area);
        assert_eq!(got, Rect::new(20, 16, 60, 8));
    }

    #[test]
    fn centered_rect_respects_minimum_size() {
        let area = Rect::new(0, 0, 80, 24);
        let got = centered_rect(60, 20, 40, 6, area);
        assert_eq!(got.height, 6);
    }

    #[test]
    fn file_columns_hide_size_on_narrow_widths() {
        assert_eq!(file_columns(10, false), (8, 0, 0, 0));
    }

    #[test]
    fn file_columns_keep_size_when_space_allows() {
        assert_eq!(file_columns(30, false), (14, 12, 0, 0));
    }

    #[test]
    fn file_columns_show_modified_in_wide_view() {
        let (name_width, size_width, modified_width, bar_width) = file_columns(60, true);
        assert!(size_width > 0);
        assert!(modified_width > 0);
        assert!(bar_width > 0);
        // Account for ICON_WIDTH (2) + GAP_WIDTH (2) * 3 = 8
        assert_eq!(name_width + size_width + modified_width + bar_width + 8, 60);
        assert_eq!(bar_width, 10);
    }

    #[test]
    fn file_columns_swap_modified_when_space_is_limited() {
        let (name_width, size_width, modified_width, bar_width) = file_columns(35, true);
        assert!(size_width == 0);
        assert_eq!(name_width + modified_width + 4, 35);
        assert!(modified_width >= 4);
        assert_eq!(bar_width, 0);
    }

    #[test]
    fn file_columns_show_bar_at_wide_width() {
        let (name_width, size_width, modified_width, bar_width) = file_columns(90, false);
        assert_eq!(modified_width, 0);
        assert_eq!(bar_width, 10);
        // Account for ICON_WIDTH (2) + GAP_WIDTH (2) * 2 = 6
        assert_eq!(name_width + size_width + bar_width + 6, 90);
    }

    #[test]
    fn file_size_bar_renders_scaled_blocks() {
        let (bar, percent, _style) = file_size_bar(Some(50), 100, 200, 10);
        // bar.len() returns bytes (█ is 3 bytes), use chars().count() for visual width
        assert_eq!(bar.chars().count(), 10);
        assert_eq!(percent, " 25%");
    }

    #[test]
    fn file_size_bar_handles_unknown_size() {
        let (bar, percent, _style) = file_size_bar(None, 100, 200, 10);
        assert_eq!(bar.len(), 10);
        assert_eq!(percent, " --%");
    }

    #[test]
    fn package_columns_hide_size_when_narrow() {
        assert_eq!(package_columns(12), (12, 0));
    }

    #[test]
    fn side_panel_keeps_room_for_packages() {
        assert_eq!(side_panel_heights(21, 12, true), (14, 7));
    }

    #[test]
    fn disk_window_tracks_selected_disk() {
        assert_eq!(disk_window_bounds(0, 12, 3), (0, 3));
        assert_eq!(disk_window_bounds(8, 12, 3), (7, 10));
        assert_eq!(disk_window_bounds(11, 12, 3), (9, 12));
    }

    #[test]
    fn file_window_bounds_scrolls_to_keep_selection_visible() {
        assert_eq!(file_window_bounds(0, 100, 0, 10), (0, 10));
        assert_eq!(file_window_bounds(9, 100, 0, 10), (0, 10));
        assert_eq!(file_window_bounds(10, 100, 0, 10), (1, 11));
        assert_eq!(file_window_bounds(50, 100, 40, 10), (41, 51));
    }
}

fn rounded_percent(value: u64, total: u64) -> u64 {
    value
        .saturating_mul(100)
        .saturating_add(total / 2)
        .checked_div(total)
        .unwrap_or(0)
        .min(100)
}

fn spinner_char() -> char {
    let spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
    let ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    let index = ((ms / 60) % spinners.len() as u128) as usize;
    spinners[index]
}

fn big_spinner_char() -> char {
    let spinners = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
    let ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    let index = ((ms / 120) % spinners.len() as u128) as usize;
    spinners[index]
}

fn activity_bar(width: usize) -> String {
    let bar_width = 20.min(width.saturating_sub(10)).max(10);
    let ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);

    let period = 2000; // 2 seconds back and forth
    let pos_t = (ms % period) as f64 / period as f64; // 0.0 to 1.0
    let pos = if pos_t < 0.5 {
        pos_t * 2.0
    } else {
        2.0 - pos_t * 2.0
    };

    let active_pos = (pos * (bar_width - 1) as f64).round() as usize;

    let mut bar = vec!['·'; bar_width];
    bar[active_pos] = '●';

    format!(
        "  {}  ",
        bar.into_iter()
            .map(|c| format!("{c} "))
            .collect::<String>()
            .trim_end()
    )
}
