use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc;
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};

use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::style::{style, Stylize};
use crossterm::{cursor, queue, terminal};

use crate::cache::Cache;
use crate::plugin::{CleanAction, CleanTarget, Safety};
use crate::scanner::{LiveScan, ScanEvent};

const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

fn colored_size(size: u64) -> String {
    let s = bytesize::ByteSize(size).to_string();
    if size >= 2_000_000_000 {
        format!("{}", style(s).red().bold())
    } else if size >= 500_000_000 {
        format!("{}", style(s).yellow().bold())
    } else {
        format!("{}", style(s).green())
    }
}

struct TargetItem {
    id: usize,
    target: CleanTarget,
    status: ItemStatus,
}

#[derive(PartialEq)]
enum ItemStatus {
    Normal,
    Deleting,
    Deleted,
    Failed,
}

enum DeleteEvent {
    Done {
        id: usize,
        freed: u64,
        cache_key: PathBuf,
    },
    Error {
        id: usize,
        error: String,
    },
}

#[derive(Clone, Copy, PartialEq)]
enum SortMode {
    Default,
    SizeDesc,
    Plugin,
    Name,
}

impl SortMode {
    fn next(self) -> Self {
        match self {
            SortMode::Default => SortMode::SizeDesc,
            SortMode::SizeDesc => SortMode::Plugin,
            SortMode::Plugin => SortMode::Name,
            SortMode::Name => SortMode::Default,
        }
    }

    fn label(self) -> &'static str {
        match self {
            SortMode::Default => "default",
            SortMode::SizeDesc => "size",
            SortMode::Plugin => "plugin",
            SortMode::Name => "name",
        }
    }
}

pub struct InteractiveResult {
    pub total_freed: u64,
    pub target_count: usize,
    pub errors: Vec<String>,
}

/// Guard that restores terminal state when dropped (on normal exit or panic).
struct TermGuard;

impl Drop for TermGuard {
    fn drop(&mut self) {
        let _ = crossterm::execute!(
            std::io::stderr(),
            cursor::Show,
            terminal::LeaveAlternateScreen
        );
        let _ = terminal::disable_raw_mode();
    }
}

/// Count visible characters in a string, skipping ANSI escape sequences.
fn visible_width(s: &str) -> usize {
    let mut width = 0;
    let mut in_escape = false;
    for c in s.chars() {
        if in_escape {
            if c.is_ascii_alphabetic() {
                in_escape = false;
            }
        } else if c == '\x1b' {
            in_escape = true;
        } else {
            width += 1;
        }
    }
    width
}

/// A frame buffer that batches all crossterm commands and text into a single byte buffer,
/// then flushes to the terminal in one write_all + flush call to prevent flickering.
struct FrameBuffer {
    buf: Vec<u8>,
    row_count: usize,
    term_cols: u16,
}

impl FrameBuffer {
    fn new(term_cols: u16) -> Self {
        FrameBuffer {
            buf: Vec::with_capacity(8192),
            row_count: 0,
            term_cols,
        }
    }

    /// Track the visual rows a line occupies (may wrap if wider than terminal).
    fn add_visual_rows(&mut self, line: &str) {
        let w = visible_width(line);
        if w == 0 || self.term_cols == 0 {
            self.row_count += 1;
        } else {
            self.row_count += w.div_ceil(self.term_cols as usize);
        }
    }

    fn push_line(&mut self, line: &str) {
        let _ = write!(self.buf, "{}\r\n", line);
        self.add_visual_rows(line);
    }

    fn push_empty(&mut self) {
        let _ = write!(self.buf, "\r\n");
        self.row_count += 1;
    }

    /// Atomically clear the previous frame and write the new one.
    /// All crossterm commands and content are batched into a single write.
    fn flush(self, out: &mut impl Write, prev_rows: usize) -> anyhow::Result<usize> {
        let mut batch = Vec::with_capacity(self.buf.len() + 64);

        if prev_rows > 0 {
            queue!(
                batch,
                cursor::MoveUp(prev_rows as u16),
                terminal::Clear(terminal::ClearType::FromCursorDown)
            )?;
        }

        batch.extend_from_slice(&self.buf);

        out.write_all(&batch)?;
        out.flush()?;

        Ok(self.row_count)
    }
}

/// Compute indices into `items` that are currently visible based on the unsafe toggle.
fn compute_visible(items: &[TargetItem], show_unsafe: bool) -> Vec<usize> {
    items
        .iter()
        .enumerate()
        .filter(|(_, item)| show_unsafe || item.target.safety != Safety::Unsafe)
        .map(|(i, _)| i)
        .collect()
}

/// Interactive UI: navigate with arrows, press enter/delete to clean the current item.
pub fn interactive_select(scan: LiveScan, cache: &Cache) -> anyhow::Result<InteractiveResult> {
    let mut stderr = std::io::stderr();

    terminal::enable_raw_mode()?;
    queue!(stderr, terminal::EnterAlternateScreen, cursor::Hide)?;
    stderr.flush()?;
    let _term_guard = TermGuard;

    let mut items: Vec<TargetItem> = Vec::new();
    let mut scan_errors: Vec<String> = Vec::new();
    let mut cursor_pos: usize = 0;
    let mut scroll_offset: usize = 0;
    let mut done_count: usize = 0;
    let mut scan_complete = false;
    let plugin_count = scan.plugin_count;
    let scan_rx = scan.rx;

    let mut next_id: usize = 0;
    let mut sort_mode = SortMode::Default;
    let mut needs_sort = false;

    let mut show_unsafe = false;
    let mut show_explanations = false;
    let mut visible: Vec<usize> = Vec::new();
    let mut visible_dirty = true;
    let mut cursor_id: Option<usize> = None;

    let mut del_freed: u64 = 0;
    let mut total_freed: u64 = 0;
    let mut total_deleted: usize = 0;
    let mut del_errors: Vec<String> = Vec::new();
    let mut cleanup_timer: Option<Instant> = None;

    let mut col_widths: ColumnWidths = ColumnWidths::default();
    let mut col_widths_dirty = true;

    let mut prev_rows: usize = 0;
    let start = Instant::now();

    loop {
        // Drain scan results (non-blocking)
        if !scan_complete {
            loop {
                match scan_rx.try_recv() {
                    Ok(ScanEvent::Target(t)) => {
                        cache.put(&t);
                        let id = next_id;
                        next_id += 1;
                        items.push(TargetItem {
                            id,
                            target: t,
                            status: ItemStatus::Normal,
                        });
                        col_widths_dirty = true;
                        needs_sort = true;
                        visible_dirty = true;
                    }
                    Ok(ScanEvent::Error(e)) => scan_errors.push(e),
                    Ok(ScanEvent::Done) => {
                        done_count += 1;
                        if done_count >= plugin_count {
                            scan_complete = true;
                        }
                    }
                    Err(mpsc::TryRecvError::Empty) => break,
                    Err(mpsc::TryRecvError::Disconnected) => {
                        if !scan_complete {
                            scan_complete = true;
                        }
                        break;
                    }
                }
            }
        }

        // Drain deletion results (non-blocking)
        let mut any_deleting = false;
        for item in items.iter() {
            if item.status == ItemStatus::Deleting {
                any_deleting = true;
                break;
            }
        }

        if any_deleting {
            // Check for completed deletions via channels stored in items
            // (handled inline below)
        }

        // Process deletion completions
        drain_delete_events(&mut items, &mut del_freed, &mut del_errors, cache);

        // Check if all in-flight deletions are done
        let still_deleting = items.iter().any(|i| i.status == ItemStatus::Deleting);
        if !still_deleting && del_freed > 0 {
            total_freed += del_freed;
            del_freed = 0;
            cleanup_timer = Some(Instant::now());
        }

        // After a brief delay, remove completed items
        if let Some(timer) = cleanup_timer {
            if timer.elapsed() >= Duration::from_millis(400) {
                cleanup_timer = None;

                total_deleted += items
                    .iter()
                    .filter(|i| matches!(i.status, ItemStatus::Deleted))
                    .count();

                items.retain(|item| {
                    !matches!(item.status, ItemStatus::Deleted | ItemStatus::Failed)
                });
                col_widths_dirty = true;
                needs_sort = true;
                visible_dirty = true;
            }
        }

        // Auto-exit if scan done and no items remain
        if scan_complete && items.is_empty() && !still_deleting && cleanup_timer.is_none() {
            if prev_rows > 0 {
                let mut buf = Vec::new();
                queue!(
                    buf,
                    cursor::MoveUp(prev_rows as u16),
                    terminal::Clear(terminal::ClearType::FromCursorDown)
                )?;
                stderr.write_all(&buf)?;
                stderr.flush()?;
            }
            if total_freed > 0 {
                return Ok(InteractiveResult {
                    total_freed,
                    target_count: total_deleted,
                    errors: del_errors,
                });
            }
            let _ = write!(
                stderr,
                "\r\n  No targets found above the minimum size threshold.\r\n\r\n"
            );
            stderr.flush()?;
            return Ok(InteractiveResult {
                total_freed: 0,
                target_count: 0,
                errors: del_errors,
            });
        }

        // Apply sort if needed
        if needs_sort && sort_mode != SortMode::Default {
            sort_items(&mut items, sort_mode);
            needs_sort = false;
            visible_dirty = true;
        }

        // Recompute visible indices
        if visible_dirty {
            visible = compute_visible(&items, show_unsafe);
            visible_dirty = false;

            // Restore cursor to the same item by id
            let restored = cursor_id.and_then(|id| visible.iter().position(|&i| items[i].id == id));

            if let Some(pos) = restored {
                cursor_pos = pos;
            } else if visible.is_empty() {
                cursor_pos = 0;
            } else {
                cursor_pos = cursor_pos.min(visible.len() - 1);
            }

            cursor_id = if !visible.is_empty() {
                Some(items[visible[cursor_pos]].id)
            } else {
                None
            };

            scroll_offset = scroll_offset.min(cursor_pos);
            col_widths_dirty = true;
        }

        if col_widths_dirty {
            col_widths = ColumnWidths::compute(&items, &visible);
            col_widths_dirty = false;
        }

        let (term_cols, term_rows) = terminal::size().unwrap_or((80, 24));
        let max_visible = (term_rows as usize).saturating_sub(7).max(3);

        // Build frame
        let spinner_frame = (start.elapsed().as_millis() / 80) as usize;
        let mut frame = FrameBuffer::new(term_cols);

        render_header(
            &mut frame,
            &items,
            &visible,
            scan_complete,
            spinner_frame,
            total_freed,
            del_freed,
            sort_mode,
            show_unsafe,
        );

        frame.push_empty();

        if visible.is_empty() {
            if !scan_complete {
                frame.push_line(&format!("    {}", "Waiting for results...".dim()));
            } else if !items.is_empty() {
                frame.push_line(&format!(
                    "    {}",
                    "No safe targets. Press 'u' to show unsafe targets.".dim()
                ));
            }
        } else {
            render_items(
                &mut frame,
                &items,
                &visible,
                cursor_pos,
                &mut scroll_offset,
                max_visible,
                spinner_frame,
                &col_widths,
                show_explanations,
            );
        }

        prev_rows = frame.flush(&mut stderr, prev_rows)?;

        // Wait for input
        let timeout = if scan_complete && !still_deleting && cleanup_timer.is_none() {
            Duration::from_millis(50)
        } else {
            Duration::from_millis(16)
        };

        if event::poll(timeout)? {
            if let Event::Key(key_event) = event::read()? {
                if key_event.kind == KeyEventKind::Press {
                    let is_escape = matches!(key_event.code, KeyCode::Esc);
                    let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
                        && key_event.modifiers.contains(event::KeyModifiers::CONTROL);

                    handle_key(
                        key_event.code,
                        &mut items,
                        &visible,
                        &mut cursor_pos,
                        &mut col_widths_dirty,
                        &mut sort_mode,
                        &mut needs_sort,
                        &mut show_unsafe,
                        &mut visible_dirty,
                        &mut show_explanations,
                    );

                    // Update cursor_id after navigation (skip if visible is stale)
                    if !visible_dirty && !visible.is_empty() && cursor_pos < visible.len() {
                        cursor_id = Some(items[visible[cursor_pos]].id);
                    }

                    if is_escape || is_ctrl_c {
                        let mut buf = Vec::new();
                        queue!(
                            buf,
                            cursor::MoveUp(prev_rows as u16),
                            terminal::Clear(terminal::ClearType::FromCursorDown)
                        )?;
                        stderr.write_all(&buf)?;
                        stderr.flush()?;
                        return Ok(InteractiveResult {
                            total_freed,
                            target_count: total_deleted,
                            errors: del_errors,
                        });
                    }
                }
            }
        }
    }
}

/// Global channel for receiving delete events from background threads.
static DELETE_TX: std::sync::OnceLock<Mutex<Option<mpsc::Sender<DeleteEvent>>>> =
    std::sync::OnceLock::new();
static DELETE_RX: std::sync::OnceLock<Mutex<mpsc::Receiver<DeleteEvent>>> =
    std::sync::OnceLock::new();

fn init_delete_channel() {
    DELETE_TX.get_or_init(|| {
        let (tx, rx) = mpsc::channel();
        let _ = DELETE_RX.set(Mutex::new(rx));
        Mutex::new(Some(tx))
    });
}

fn get_delete_sender() -> mpsc::Sender<DeleteEvent> {
    init_delete_channel();
    DELETE_TX
        .get()
        .unwrap()
        .lock()
        .unwrap()
        .as_ref()
        .unwrap()
        .clone()
}

fn drain_delete_events(
    items: &mut [TargetItem],
    del_freed: &mut u64,
    del_errors: &mut Vec<String>,
    cache: &Cache,
) {
    init_delete_channel();
    let rx = DELETE_RX.get().unwrap().lock().unwrap();
    loop {
        match rx.try_recv() {
            Ok(DeleteEvent::Done {
                id,
                freed,
                cache_key,
            }) => {
                if let Some(item) = items.iter_mut().find(|i| i.id == id) {
                    item.status = ItemStatus::Deleted;
                }
                *del_freed += freed;
                let _ = cache.remove(&cache_key);
            }
            Ok(DeleteEvent::Error { id, error }) => {
                if let Some(item) = items.iter_mut().find(|i| i.id == id) {
                    item.status = ItemStatus::Failed;
                }
                del_errors.push(error);
            }
            Err(_) => break,
        }
    }
}

fn sort_items(items: &mut [TargetItem], mode: SortMode) {
    match mode {
        SortMode::Default => items.sort_by_key(|i| i.id),
        SortMode::SizeDesc => items.sort_by(|a, b| b.target.size_bytes.cmp(&a.target.size_bytes)),
        SortMode::Plugin => items.sort_by(|a, b| {
            a.target
                .plugin_name
                .cmp(&b.target.plugin_name)
                .then(b.target.size_bytes.cmp(&a.target.size_bytes))
        }),
        SortMode::Name => items.sort_by(|a, b| a.target.label.cmp(&b.target.label)),
    }
}

#[allow(clippy::too_many_arguments)]
fn handle_key(
    key: KeyCode,
    items: &mut [TargetItem],
    visible: &[usize],
    cursor: &mut usize,
    col_widths_dirty: &mut bool,
    sort_mode: &mut SortMode,
    needs_sort: &mut bool,
    show_unsafe: &mut bool,
    visible_dirty: &mut bool,
    show_explanations: &mut bool,
) {
    match key {
        KeyCode::Up | KeyCode::Char('k') => {
            if *cursor > 0 {
                *cursor -= 1;
            }
        }
        KeyCode::Down | KeyCode::Char('j') => {
            if !visible.is_empty() && *cursor + 1 < visible.len() {
                *cursor += 1;
            }
        }
        KeyCode::Enter | KeyCode::Delete | KeyCode::Backspace => {
            if !visible.is_empty() && *cursor < visible.len() {
                let item_idx = visible[*cursor];
                if items[item_idx].status == ItemStatus::Normal {
                    delete_item(items, item_idx);
                }
            }
        }
        KeyCode::Char('s') => {
            *sort_mode = sort_mode.next();
            sort_items(items, *sort_mode);
            *needs_sort = false;
            *col_widths_dirty = true;
            *visible_dirty = true;
        }
        KeyCode::Char('u') => {
            *show_unsafe = !*show_unsafe;
            *col_widths_dirty = true;
            *visible_dirty = true;
        }
        KeyCode::Char('e') => {
            *show_explanations = !*show_explanations;
        }
        KeyCode::Esc => {
            // Handled in main loop
        }
        _ => {}
    }
}

/// Immediately start deleting a single item at the given index.
fn delete_item(items: &mut [TargetItem], index: usize) {
    let item = &mut items[index];
    if item.status != ItemStatus::Normal {
        return;
    }
    item.status = ItemStatus::Deleting;

    let id = item.id;
    let action = item.target.action.clone();
    let size_bytes = item.target.size_bytes;
    let cache_key = match &item.target.action {
        CleanAction::DeletePath(p) | CleanAction::DeleteContents(p) => p.clone(),
        CleanAction::RunCommand { description, .. } => {
            PathBuf::from(format!("__cmd__:{}", description))
        }
    };

    let tx = get_delete_sender();

    thread::spawn(move || match &action {
        CleanAction::DeletePath(path) => {
            let result = if path.is_dir() {
                fs::remove_dir_all(path)
            } else {
                fs::remove_file(path)
            };
            match result {
                Ok(()) => {
                    let _ = tx.send(DeleteEvent::Done {
                        id,
                        freed: size_bytes,
                        cache_key,
                    });
                }
                Err(e) => {
                    let _ = tx.send(DeleteEvent::Error {
                        id,
                        error: format!("{}: {}", path.display(), e),
                    });
                }
            }
        }
        CleanAction::DeleteContents(path) => match delete_contents(path) {
            Ok(()) => {
                let _ = tx.send(DeleteEvent::Done {
                    id,
                    freed: size_bytes,
                    cache_key,
                });
            }
            Err(e) => {
                let _ = tx.send(DeleteEvent::Error {
                    id,
                    error: format!("{}: {}", path.display(), e),
                });
            }
        },
        CleanAction::RunCommand {
            command,
            args,
            description,
        } => match Command::new(command).args(args).output() {
            Ok(output) if output.status.success() => {
                let _ = tx.send(DeleteEvent::Done {
                    id,
                    freed: size_bytes,
                    cache_key,
                });
            }
            Ok(output) => {
                let stderr = String::from_utf8_lossy(&output.stderr);
                let _ = tx.send(DeleteEvent::Error {
                    id,
                    error: format!("{}: {}", description, stderr.trim()),
                });
            }
            Err(e) => {
                let _ = tx.send(DeleteEvent::Error {
                    id,
                    error: format!("{}: {}", description, e),
                });
            }
        },
    });
}

/// Delete the contents of a directory without removing the directory itself.
fn delete_contents(path: &std::path::Path) -> std::io::Result<()> {
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let entry_path = entry.path();
        if entry_path.is_dir() {
            fs::remove_dir_all(&entry_path)?;
        } else {
            fs::remove_file(&entry_path)?;
        }
    }
    Ok(())
}

#[derive(Default)]
struct ColumnWidths {
    plugin: usize,
    size: usize,
}

impl ColumnWidths {
    fn compute(items: &[TargetItem], visible: &[usize]) -> Self {
        ColumnWidths {
            plugin: visible
                .iter()
                .map(|&i| items[i].target.plugin_name.len())
                .max()
                .unwrap_or(0),
            size: visible
                .iter()
                .map(|&i| {
                    bytesize::ByteSize(items[i].target.size_bytes)
                        .to_string()
                        .len()
                })
                .max()
                .unwrap_or(0),
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn render_header(
    frame: &mut FrameBuffer,
    items: &[TargetItem],
    visible: &[usize],
    scan_complete: bool,
    spinner_frame: usize,
    total_freed: u64,
    del_freed: u64,
    sort_mode: SortMode,
    show_unsafe: bool,
) {
    let ch = SPINNER_CHARS[spinner_frame % SPINNER_CHARS.len()];
    let total_size: u64 = visible
        .iter()
        .map(|&i| &items[i])
        .filter(|i| i.status == ItemStatus::Normal)
        .map(|i| i.target.size_bytes)
        .sum();

    // Freed indicator
    let freed_str = if del_freed > 0 {
        let current = total_freed + del_freed;
        format!(
            " | {} {}",
            style(ch).yellow(),
            style(bytesize::ByteSize(current).to_string())
                .yellow()
                .bold(),
        )
    } else if total_freed > 0 {
        format!(
            " | {} freed {}",
            style("✓").green().bold(),
            style(bytesize::ByteSize(total_freed).to_string())
                .green()
                .bold(),
        )
    } else {
        String::new()
    };

    // Sort indicator
    let sort_str = if sort_mode != SortMode::Default {
        format!(" | sort: {}", style(sort_mode.label()).dim())
    } else {
        String::new()
    };

    // Unsafe toggle indicator
    let unsafe_label = if show_unsafe {
        format!("{}", style("u").bold().red())
    } else {
        format!("{}", style("u").bold().blue())
    };
    let unsafe_str = if show_unsafe {
        format!(" | {}", style("unsafe: on").red())
    } else {
        let hidden_size: u64 = items
            .iter()
            .filter(|i| i.target.safety == Safety::Unsafe && i.status == ItemStatus::Normal)
            .map(|i| i.target.size_bytes)
            .sum();
        if hidden_size > 0 {
            format!(
                " | {} unsafe hidden",
                style(bytesize::ByteSize(hidden_size).to_string())
                    .red()
                    .dim(),
            )
        } else {
            String::new()
        }
    };

    if !scan_complete {
        frame.push_line(&format!(
            "  {} {} {} found ({}){}{}{} | {} delete  {} sort  {} unsafe  {} explain  {} quit",
            style(ch).cyan(),
            style("Scanning...").bold(),
            style(visible.len()).cyan(),
            style(bytesize::ByteSize(total_size).to_string())
                .yellow()
                .bold(),
            freed_str,
            sort_str,
            unsafe_str,
            style("enter").bold().green(),
            style("s").bold().blue(),
            unsafe_label,
            style("e").bold().blue(),
            style("esc").bold().red(),
        ));
    } else {
        frame.push_line(&format!(
            "  {} targets ({}){}{}{} | {} delete  {} sort  {} unsafe  {} explain  {} quit",
            style(visible.len()).cyan().bold(),
            style(bytesize::ByteSize(total_size).to_string())
                .yellow()
                .bold(),
            freed_str,
            sort_str,
            unsafe_str,
            style("enter").bold().green(),
            style("s").bold().blue(),
            unsafe_label,
            style("e").bold().blue(),
            style("esc").bold().red(),
        ));
    }
}

#[allow(clippy::too_many_arguments)]
fn render_items(
    frame: &mut FrameBuffer,
    items: &[TargetItem],
    visible: &[usize],
    cursor: usize,
    scroll_offset: &mut usize,
    max_visible: usize,
    spinner_frame: usize,
    col_widths: &ColumnWidths,
    show_explanations: bool,
) {
    // Adjust scroll (cursor and scroll_offset index into visible)
    if cursor < *scroll_offset {
        *scroll_offset = cursor;
    }
    if cursor >= *scroll_offset + max_visible {
        *scroll_offset = cursor - max_visible + 1;
    }
    let visible_end = (*scroll_offset + max_visible).min(visible.len());

    // Scroll up indicator
    if *scroll_offset > 0 {
        frame.push_line(&format!(
            "    {} {} more above",
            style("↑").dim(),
            *scroll_offset
        ));
    }

    for vi in *scroll_offset..visible_end {
        let item = &items[visible[vi]];
        let target = &item.target;
        let is_cur = vi == cursor;

        // Cursor / status prefix
        let prefix = match item.status {
            ItemStatus::Deleting => {
                let ch = SPINNER_CHARS[spinner_frame % SPINNER_CHARS.len()];
                format!("{}", style(ch).yellow())
            }
            ItemStatus::Deleted => format!("{}", style("✓").green().bold()),
            ItemStatus::Failed => format!("{}", style("✗").red().bold()),
            ItemStatus::Normal if is_cur => format!("{}", style("›").cyan().bold()),
            ItemStatus::Normal => " ".to_string(),
        };

        // Size (right-aligned, before the plugin name for easy scanning)
        let size_str = bytesize::ByteSize(target.size_bytes).to_string();
        let size_pad = col_widths.size.saturating_sub(size_str.len());
        let size_colored = colored_size(target.size_bytes);
        let size = format!("{}{}", " ".repeat(size_pad), size_colored);

        // Plugin name
        let plugin_styled = if is_cur {
            format!("{}", style(&target.plugin_name).magenta().bold())
        } else {
            format!("{}", style(&target.plugin_name).magenta())
        };
        let plugin_pad = col_widths.plugin.saturating_sub(target.plugin_name.len());
        let plugin = format!("{}{}", plugin_styled, " ".repeat(plugin_pad));

        // Label (path)
        let label_styled = match item.status {
            ItemStatus::Deleted => format!("{}", style(&target.label).dim().crossed_out()),
            ItemStatus::Failed => format!("{}", style(&target.label).red().dim()),
            _ if is_cur => format!("{}", style(&target.label).bold()),
            _ => format!("{}", style(&target.label).dim()),
        };

        // Safety
        let safety_str = match target.safety {
            Safety::Recoverable => format!("{}", style("safe").green()),
            Safety::Unsafe => format!("{}", style("UNSAFE").red().bold()),
        };

        // Inline explanation or hint to press 'e'
        let explanation_suffix = if !target.explanation.is_empty() {
            if show_explanations {
                format!(" {} {}", style("·").dim(), style(&target.explanation).dim())
            } else {
                format!(
                    " {} {}",
                    style("·").dim(),
                    style("explanation").dim().italic()
                )
            }
        } else {
            String::new()
        };

        let line = format!(
            "  {} {} {} {} {} {}{}",
            prefix,
            size,
            plugin,
            label_styled,
            style("·").dim(),
            safety_str,
            explanation_suffix,
        );
        frame.push_line(&line);
    }

    // Scroll down indicator
    let remaining = visible.len().saturating_sub(visible_end);
    if remaining > 0 {
        frame.push_line(&format!(
            "    {} {} more below",
            style("↓").dim(),
            remaining
        ));
    }
}

/// Print final results.
pub fn print_done(freed_bytes: u64, error_count: usize) {
    if freed_bytes > 0 || error_count > 0 {
        println!(
            "\n  {} Freed {}. {} error(s).\n",
            style("Done.").bold().green(),
            style(bytesize::ByteSize(freed_bytes).to_string())
                .bold()
                .yellow(),
            error_count,
        );
    }
}
