mod aisling;
mod procfs;
mod scrin;

use std::cmp::Ordering;
use std::collections::{HashMap, HashSet, VecDeque};
use std::env;
use std::io;
use std::io::{Read, Write};
use std::thread;
use std::time::{Duration, Instant};

use aisling::Aisling;
use crossterm::event::{
    self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use procfs::{
    ProcessInfo, Sampler, SystemView, CMDLINE_LIMIT, CMD_RETAIN_LIMIT, CPU_SAMPLE_LIMIT,
    PASSWD_BYTES_LIMIT, PROCESS_NAME_RETAIN_LIMIT, PROCESS_SAMPLE_LIMIT, PROC_LOADAVG_BYTES_LIMIT,
    PROC_MEMINFO_BYTES_LIMIT, PROC_PID_STAT_BYTES_LIMIT, PROC_STAT_BYTES_LIMIT,
    PROC_UPTIME_BYTES_LIMIT, SEARCH_TEXT_RETAIN_LIMIT, USER_CACHE_LIMIT, USER_RETAIN_LIMIT,
};
use scrin::{Color, Frame, Rect, Style, Term, MAX_FRAME_CELLS, MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH};

const REFRESH_EVERY: Duration = Duration::from_millis(650);
const DRAW_EVERY: Duration = Duration::from_millis(100);
const IDLE_POLL: Duration = Duration::from_millis(80);

// Long-running resource caps: every retained TUI buffer has an explicit bound.
const HISTORY_LIMIT: usize = 96;
const SPOTLIGHT_QUERY_LIMIT: usize = 256;
const STATUS_LIMIT: usize = 240;
const QUERY_TOKEN_LIMIT: usize = 64;
const QUERY_TOKEN_BYTE_LIMIT: usize = 96;
const PID_TARGET_LIMIT: usize = 1024;
const CLI_OUTPUT_LIMIT: usize = 10_000;
const CLI_PREVIEW_LIMIT: usize = 20;
const CONFIRM_INPUT_LIMIT: usize = 16;
const STABILITY_DEFAULT_SAMPLES: usize = 60;
const STABILITY_SAMPLE_LIMIT: usize = 10_000;
const STABILITY_DEFAULT_INTERVAL_MS: u64 = 250;
const STABILITY_INTERVAL_LIMIT_MS: u64 = 60_000;

fn main() -> io::Result<()> {
    match CliMode::parse(env::args().skip(1))? {
        CliMode::Tui => run_tui(),
        CliMode::Help => {
            print_usage();
            Ok(())
        }
        CliMode::Version => {
            println!("ktop {}", env!("CARGO_PKG_VERSION"));
            Ok(())
        }
        CliMode::Grep { query, limit } => run_cli_grep(&query, limit),
        CliMode::Kill {
            target,
            signal,
            yes,
        } => run_cli_kill(&target, signal, yes),
        CliMode::Anomalies { limit } => run_cli_anomalies(limit),
        CliMode::Diagnostics => run_cli_diagnostics(),
        CliMode::StabilityCheck { samples, interval } => run_cli_stability_check(samples, interval),
    }
}

fn run_tui() -> io::Result<()> {
    let mut term = Term::enter()?;
    let mut app = App::new();
    let (width, height) = term.size()?;
    let mut frame = Frame::new(width, height, app.base_style());
    app.draw_boot_loader(&mut frame, "warming procfs sampler");
    term.draw(&frame)?;

    app.refresh();
    let mut needs_draw = true;
    let mut last_draw = Instant::now() - DRAW_EVERY;

    loop {
        if !app.paused && app.last_refresh.elapsed() >= REFRESH_EVERY {
            app.refresh();
            needs_draw = true;
        }

        if needs_draw || last_draw.elapsed() >= DRAW_EVERY {
            let (width, height) = term.size()?;
            frame.resize(width, height, app.base_style());
            app.draw(&mut frame);
            term.draw(&frame)?;
            needs_draw = false;
            last_draw = Instant::now();
        }

        let refresh_wait = if app.paused {
            IDLE_POLL
        } else {
            REFRESH_EVERY.saturating_sub(app.last_refresh.elapsed())
        };
        let draw_wait = DRAW_EVERY.saturating_sub(last_draw.elapsed());
        let wait = refresh_wait.min(draw_wait).min(IDLE_POLL);

        if event::poll(wait)? {
            match event::read()? {
                Event::Key(key) => {
                    if app.handle_key(key) {
                        break;
                    }
                    needs_draw = true;
                }
                Event::Mouse(mouse) => {
                    app.handle_mouse(mouse);
                    needs_draw = true;
                }
                _ => {}
            }
        }
    }

    Ok(())
}

enum CliMode {
    Tui,
    Help,
    Version,
    Grep {
        query: String,
        limit: usize,
    },
    Kill {
        target: String,
        signal: SignalKind,
        yes: bool,
    },
    Anomalies {
        limit: usize,
    },
    Diagnostics,
    StabilityCheck {
        samples: usize,
        interval: Duration,
    },
}

impl CliMode {
    fn parse(args: impl Iterator<Item = String>) -> io::Result<Self> {
        let mut action = CliAction::Tui;
        let mut signal = SignalKind::Term;
        let mut yes = false;
        let mut limit = 50;
        let mut samples = STABILITY_DEFAULT_SAMPLES;
        let mut interval = Duration::from_millis(STABILITY_DEFAULT_INTERVAL_MS);
        let mut saw_option = false;
        let mut args = args.peekable();

        if args.peek().is_none() {
            return Ok(Self::Tui);
        }

        while let Some(arg) = args.next() {
            saw_option = true;
            match arg.as_str() {
                "-h" | "--help" => return Ok(Self::Help),
                "-V" | "--version" => return Ok(Self::Version),
                "--grep" => action = CliAction::Grep(next_arg(&mut args, "--grep")?),
                "--kill" => action = CliAction::Kill(next_arg(&mut args, "--kill")?),
                "--anomalies" => action = CliAction::Anomalies,
                "--diagnostics" => action = CliAction::Diagnostics,
                "--stability-check" => action = CliAction::StabilityCheck,
                "--signal" => {
                    signal =
                        SignalKind::parse(&next_arg(&mut args, "--signal")?).ok_or_else(|| {
                            io::Error::new(io::ErrorKind::InvalidInput, "unknown signal")
                        })?;
                }
                "--yes" | "-y" => yes = true,
                "--limit" => {
                    limit = next_bounded_usize(&mut args, "--limit", 0, CLI_OUTPUT_LIMIT)?;
                }
                "--samples" => {
                    samples =
                        next_bounded_usize(&mut args, "--samples", 1, STABILITY_SAMPLE_LIMIT)?;
                }
                "--interval-ms" => {
                    interval = Duration::from_millis(next_bounded_u64(
                        &mut args,
                        "--interval-ms",
                        1,
                        STABILITY_INTERVAL_LIMIT_MS,
                    )?);
                }
                _ => {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidInput,
                        format!("unknown argument: {arg}"),
                    ));
                }
            }
        }

        Ok(match action {
            CliAction::Tui if saw_option => {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    "missing --grep, --kill, --anomalies, --diagnostics, or --stability-check",
                ));
            }
            CliAction::Tui => Self::Tui,
            CliAction::Grep(query) => Self::Grep { query, limit },
            CliAction::Kill(target) => Self::Kill {
                target,
                signal,
                yes,
            },
            CliAction::Anomalies => Self::Anomalies { limit },
            CliAction::Diagnostics => Self::Diagnostics,
            CliAction::StabilityCheck => Self::StabilityCheck { samples, interval },
        })
    }
}

enum CliAction {
    Tui,
    Grep(String),
    Kill(String),
    Anomalies,
    Diagnostics,
    StabilityCheck,
}

fn next_arg(args: &mut impl Iterator<Item = String>, flag: &str) -> io::Result<String> {
    args.next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{flag} needs a value")))
}

fn next_bounded_usize(
    args: &mut impl Iterator<Item = String>,
    flag: &str,
    min: usize,
    max: usize,
) -> io::Result<usize> {
    let value = next_arg(args, flag)?.parse::<usize>().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{flag} must be a number"),
        )
    })?;
    if value < min || value > max {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{flag} must be between {min} and {max}"),
        ));
    }
    Ok(value)
}

fn next_bounded_u64(
    args: &mut impl Iterator<Item = String>,
    flag: &str,
    min: u64,
    max: u64,
) -> io::Result<u64> {
    let value = next_arg(args, flag)?.parse::<u64>().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{flag} must be a number"),
        )
    })?;
    if value < min || value > max {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{flag} must be between {min} and {max}"),
        ));
    }
    Ok(value)
}

fn print_usage() {
    println!(
        "ktop {}\n\nUSAGE:\n  ktop\n  ktop --version\n  ktop --grep <spotlight-query> [--limit N]\n  ktop --kill <pid|spotlight-query> [--signal TERM|KILL|STOP|CONT|INT|HUP] [--yes]\n  ktop --anomalies [--limit N]\n  ktop --diagnostics\n  ktop --stability-check [--samples N] [--interval-ms N]\n\nExamples:\n  ktop --grep 'cmd:python user:root'\n  ktop --kill 'cmd:firefox' --signal TERM\n  ktop --kill 1234 --signal KILL --yes\n  ktop --anomalies --limit 20\n  ktop --diagnostics\n  ktop --stability-check --samples 120 --interval-ms 500",
        env!("CARGO_PKG_VERSION")
    );
}

fn run_cli_grep(query: &str, limit: usize) -> io::Result<()> {
    let view = sample_for_query(query)?;
    let query = Query::parse(query);
    let mut matches = view
        .processes
        .iter()
        .filter(|process| query.matches(process))
        .collect::<Vec<_>>();
    matches.sort_by(|left, right| compare_processes(left, right, SortMode::Cpu).reverse());

    print_process_table(matches.into_iter().take(limit), false);
    Ok(())
}

fn run_cli_anomalies(limit: usize) -> io::Result<()> {
    let view = sample_with_cpu()?;
    let mut matches = view
        .processes
        .iter()
        .filter(|process| is_anomaly(process))
        .collect::<Vec<_>>();
    matches.sort_by(|left, right| {
        anomaly_score(right)
            .partial_cmp(&anomaly_score(left))
            .unwrap_or(Ordering::Equal)
            .then_with(|| left.pid.cmp(&right.pid))
    });

    print_process_table(matches.into_iter().take(limit), true);
    Ok(())
}

fn run_cli_diagnostics() -> io::Result<()> {
    let mut sampler = Sampler::new();
    let view = sampler.sample()?;
    let self_pid = std::process::id();
    let self_rss = self_rss_from_view(&view, self_pid);

    println!("ktop diagnostics");
    println!("version: {}", env!("CARGO_PKG_VERSION"));
    println!("pid: {self_pid}");
    println!("self rss: {}", format_bytes(self_rss));
    println!("sampled processes: {}", view.processes.len());
    println!("sampled threads: {}", view.thread_count);
    println!(
        "sample cap hits: process {} / CPU {}",
        yes_no(view.limits.process_limit_hit),
        yes_no(view.limits.cpu_limit_hit)
    );
    println!("sampler process cache: {}", sampler.cached_processes());
    println!("sampler user cache: {}", sampler.cached_users());
    println!("history cap: {HISTORY_LIMIT} samples per meter");
    println!("spotlight cap: {SPOTLIGHT_QUERY_LIMIT} bytes");
    println!("query cap: {QUERY_TOKEN_LIMIT} tokens x {QUERY_TOKEN_BYTE_LIMIT} bytes");
    println!("CLI output cap: {CLI_OUTPUT_LIMIT} rows");
    println!("PID target cap: {PID_TARGET_LIMIT} entries");
    println!("process sample cap: {PROCESS_SAMPLE_LIMIT} entries");
    println!("CPU sample cap: {CPU_SAMPLE_LIMIT} cpu lines");
    println!("cmdline cap: {CMDLINE_LIMIT} bytes per process");
    println!(
        "retained text caps: user {} / name {} / cmd {} / search {}",
        format_bytes(USER_RETAIN_LIMIT as u64),
        format_bytes(PROCESS_NAME_RETAIN_LIMIT as u64),
        format_bytes(CMD_RETAIN_LIMIT as u64),
        format_bytes(SEARCH_TEXT_RETAIN_LIMIT as u64)
    );
    println!(
        "proc text caps: stat {} / meminfo {} / loadavg {} / uptime {} / pid-stat {}",
        format_bytes(PROC_STAT_BYTES_LIMIT as u64),
        format_bytes(PROC_MEMINFO_BYTES_LIMIT as u64),
        format_bytes(PROC_LOADAVG_BYTES_LIMIT as u64),
        format_bytes(PROC_UPTIME_BYTES_LIMIT as u64),
        format_bytes(PROC_PID_STAT_BYTES_LIMIT as u64)
    );
    println!("passwd cache cap: {PASSWD_BYTES_LIMIT} bytes / {USER_CACHE_LIMIT} entries");
    println!("frame cap: {MAX_FRAME_WIDTH}x{MAX_FRAME_HEIGHT} / {MAX_FRAME_CELLS} cells");
    Ok(())
}

fn run_cli_stability_check(samples: usize, interval: Duration) -> io::Result<()> {
    let mut sampler = Sampler::new();
    let self_pid = std::process::id();
    let started = Instant::now();
    let mut first_rss = None;
    let mut last_rss = 0;
    let mut min_rss = u64::MAX;
    let mut max_rss = 0;
    let mut min_processes = usize::MAX;
    let mut max_processes = 0;
    let mut max_threads = 0;
    let mut max_process_cache = 0;
    let mut max_user_cache = 0;
    let mut process_limit_hit = false;
    let mut cpu_limit_hit = false;

    for index in 0..samples {
        let view = sampler.sample()?;
        let rss = self_rss_from_view(&view, self_pid);
        first_rss.get_or_insert(rss);
        last_rss = rss;
        min_rss = min_rss.min(rss);
        max_rss = max_rss.max(rss);
        min_processes = min_processes.min(view.processes.len());
        max_processes = max_processes.max(view.processes.len());
        max_threads = max_threads.max(view.thread_count);
        max_process_cache = max_process_cache.max(sampler.cached_processes());
        max_user_cache = max_user_cache.max(sampler.cached_users());
        process_limit_hit |= view.limits.process_limit_hit;
        cpu_limit_hit |= view.limits.cpu_limit_hit;

        if index + 1 < samples {
            thread::sleep(interval);
        }
    }

    let first_rss = first_rss.unwrap_or(0);
    println!("ktop stability check");
    println!("samples: {samples}");
    println!("interval: {}ms", interval.as_millis());
    println!("elapsed: {:.2}s", started.elapsed().as_secs_f32());
    println!(
        "self rss: first {} / last {} / min {} / max {} / delta {}",
        format_bytes(first_rss),
        format_bytes(last_rss),
        format_bytes(min_rss),
        format_bytes(max_rss),
        format_byte_delta(first_rss, last_rss)
    );
    println!("processes: {min_processes}..{max_processes}");
    println!("threads high-water: {max_threads}");
    println!(
        "sample cap hits: process {} / CPU {}",
        yes_no(process_limit_hit),
        yes_no(cpu_limit_hit)
    );
    println!("sampler process cache high-water: {max_process_cache}");
    println!("sampler user cache high-water: {max_user_cache}");
    Ok(())
}

fn self_rss_from_view(view: &SystemView, self_pid: u32) -> u64 {
    view.processes
        .iter()
        .find(|process| process.pid == self_pid)
        .map(|process| process.rss)
        .unwrap_or(0)
}

fn run_cli_kill(target: &str, signal: SignalKind, yes: bool) -> io::Result<()> {
    let mut matches = if let Some(pids) = parse_pid_targets(target)? {
        pids.into_iter()
            .map(|pid| CliProcessTarget {
                pid,
                label: format!("pid {pid}"),
            })
            .collect::<Vec<_>>()
    } else {
        let view = sample_for_query(target)?;
        let query = Query::parse(target);
        view.processes
            .iter()
            .filter(|process| query.matches(process))
            .map(|process| CliProcessTarget {
                pid: process.pid,
                label: format!("{} {}", process.name.as_ref(), process.cmd.as_ref()),
            })
            .collect::<Vec<_>>()
    };

    let self_pid = std::process::id();
    matches.retain(|process| process.pid != self_pid);

    if matches.is_empty() {
        println!("No matching processes.");
        return Ok(());
    }

    println!("{} matching process(es):", matches.len());
    for process in matches.iter().take(CLI_PREVIEW_LIMIT) {
        println!("  {:>7}  {}", process.pid, process.label);
    }
    if matches.len() > CLI_PREVIEW_LIMIT {
        println!("  ... and {} more", matches.len() - CLI_PREVIEW_LIMIT);
    }

    if !yes && !confirm_signal(signal, matches.len())? {
        println!("Cancelled.");
        return Ok(());
    }

    let mut failures = 0;
    for process in &matches {
        match send_signal_to_pid(process.pid, signal) {
            Ok(()) => println!("sent {} to {}", signal.label(), process.pid),
            Err(error) => {
                failures += 1;
                eprintln!("failed {}: {error}", process.pid);
            }
        }
    }

    if failures > 0 {
        Err(io::Error::new(
            io::ErrorKind::Other,
            format!("{failures} signal(s) failed"),
        ))
    } else {
        Ok(())
    }
}

struct CliProcessTarget {
    pid: u32,
    label: String,
}

fn sample_once() -> io::Result<SystemView> {
    Sampler::new().sample()
}

fn sample_for_query(query: &str) -> io::Result<SystemView> {
    if query_needs_cpu(query) {
        sample_with_cpu()
    } else {
        sample_once()
    }
}

fn sample_with_cpu() -> io::Result<SystemView> {
    let mut sampler = Sampler::new();
    let _ = sampler.sample()?;
    thread::sleep(Duration::from_millis(220));
    sampler.sample()
}

fn query_needs_cpu(query: &str) -> bool {
    query.split_whitespace().any(|token| {
        let token = token.trim_start_matches('!');
        token.starts_with("cpu>") || token.starts_with("cpu<")
    })
}

fn print_process_table<'a>(processes: impl Iterator<Item = &'a ProcessInfo>, show_score: bool) {
    if show_score {
        println!(" SCORE   PID     CPU%   MEM%   AGE      S USER       NAME             CMD");
    } else {
        println!(" PID     CPU%   MEM%   AGE      S USER       NAME             CMD");
    }
    for process in processes {
        if show_score {
            print!("{:>6.1} ", anomaly_score(process));
        }
        println!(
            "{:>6} {:>7.1} {:>6.1} {:<8} {} {:<10} {:<16} {}",
            process.pid,
            process.cpu,
            process.mem,
            format_duration_short(process.runtime),
            process.state,
            process.user.as_ref(),
            process.name.as_ref(),
            process.cmd.as_ref()
        );
    }
}

fn parse_pid_targets(target: &str) -> io::Result<Option<Vec<u32>>> {
    let mut pids = Vec::new();

    for part in target.split(',').map(str::trim) {
        let Ok(pid) = part.parse::<u32>() else {
            return Ok(None);
        };
        if pids.len() >= PID_TARGET_LIMIT {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("PID target list is limited to {PID_TARGET_LIMIT} entries"),
            ));
        }
        pids.push(pid);
    }

    Ok((!pids.is_empty()).then_some(pids))
}

fn confirm_signal(signal: SignalKind, count: usize) -> io::Result<bool> {
    print!(
        "Send {} to {count} process(es)? Type yes to continue: ",
        signal.label()
    );
    io::stdout().flush()?;
    Ok(read_limited_line(CONFIRM_INPUT_LIMIT)?.trim() == "yes")
}

fn read_limited_line(limit: usize) -> io::Result<String> {
    let mut stdin = io::stdin().lock();
    let mut bytes = Vec::with_capacity(limit.min(64));

    for _ in 0..limit {
        let mut byte = [0_u8; 1];
        let read = stdin.read(&mut byte)?;
        if read == 0 || byte[0] == b'\n' {
            break;
        }
        bytes.push(byte[0]);
    }

    Ok(String::from_utf8_lossy(&bytes)
        .trim_end_matches('\r')
        .to_string())
}

fn send_signal_to_pid(pid: u32, signal: SignalKind) -> io::Result<()> {
    let pid = i32::try_from(pid)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pid out of range"))?;

    if unsafe { libc::kill(pid, signal.raw()) } == 0 {
        Ok(())
    } else {
        Err(io::Error::last_os_error())
    }
}

struct App {
    sampler: Sampler,
    view: SystemView,
    aisling: Aisling,
    selected: usize,
    selected_pid: Option<u32>,
    scroll: usize,
    sort: SortMode,
    descending: bool,
    search: String,
    visible: Vec<VisibleProcess>,
    visible_dirty: bool,
    tree_mode: bool,
    anomaly_mode: bool,
    effects_enabled: bool,
    show_details: bool,
    cpu_history: VecDeque<f32>,
    mem_history: VecDeque<f32>,
    spotlight: bool,
    paused: bool,
    last_refresh: Instant,
    status: String,
    signal_palette: Option<u32>,
    signal_confirm: Option<SignalAction>,
    hovered: Option<usize>,
    mouse_pos: Option<(u16, u16)>,
    table_hotspot: TableHotspot,
}

impl App {
    fn new() -> Self {
        Self {
            sampler: Sampler::new(),
            view: SystemView::default(),
            aisling: Aisling::new(),
            selected: 0,
            selected_pid: None,
            scroll: 0,
            sort: SortMode::Cpu,
            descending: true,
            search: String::new(),
            visible: Vec::new(),
            visible_dirty: true,
            tree_mode: false,
            anomaly_mode: false,
            effects_enabled: true,
            show_details: true,
            cpu_history: VecDeque::with_capacity(HISTORY_LIMIT),
            mem_history: VecDeque::with_capacity(HISTORY_LIMIT),
            spotlight: false,
            paused: false,
            last_refresh: Instant::now() - REFRESH_EVERY,
            status: String::from("/ opens the spotlight grep"),
            signal_palette: None,
            signal_confirm: None,
            hovered: None,
            mouse_pos: None,
            table_hotspot: TableHotspot::default(),
        }
    }

    fn base_style(&self) -> Style {
        Style::new(
            Color::Rgb {
                r: 226,
                g: 232,
                b: 240,
            },
            Color::Rgb { r: 2, g: 6, b: 23 },
        )
    }

    fn refresh(&mut self) {
        match self.sampler.sample() {
            Ok(view) => {
                self.view = view;
                self.record_history();
                self.mark_visible_dirty();
                self.rebuild_visible();
                self.last_refresh = Instant::now();
            }
            Err(error) => {
                self.set_status(format!("refresh failed: {error}"));
                self.last_refresh = Instant::now();
            }
        }
    }

    fn record_history(&mut self) {
        push_history(&mut self.cpu_history, self.view.cpu_total);
        push_history(
            &mut self.mem_history,
            percent_ratio(self.view.memory.used, self.view.memory.total) * 100.0,
        );
    }

    fn handle_key(&mut self, key: KeyEvent) -> bool {
        if self.signal_confirm.is_some() {
            return self.handle_signal_confirm_key(key);
        }

        if self.signal_palette.is_some() {
            return self.handle_signal_palette_key(key);
        }

        if self.spotlight {
            return self.handle_spotlight_key(key);
        }

        match key.code {
            KeyCode::Char('q') | KeyCode::Esc => return true,
            KeyCode::Char('/') => self.open_spotlight(),
            KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.open_spotlight()
            }
            KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
            KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
            KeyCode::PageDown => self.move_selection(12),
            KeyCode::PageUp => self.move_selection(-12),
            KeyCode::Home => self.set_selected_index(0),
            KeyCode::End => {
                let end = self.visible_count().saturating_sub(1);
                self.set_selected_index(end);
            }
            KeyCode::Tab => self.cycle_sort(),
            KeyCode::Char('c') => self.set_sort(SortMode::Cpu),
            KeyCode::Char('m') => self.set_sort(SortMode::Mem),
            KeyCode::Char('p') => self.set_sort(SortMode::Pid),
            KeyCode::Char('n') => self.set_sort(SortMode::Name),
            KeyCode::Char('u') => self.set_sort(SortMode::User),
            KeyCode::Char('t') => self.set_sort(SortMode::Threads),
            KeyCode::Char('a') => self.set_sort(SortMode::Age),
            KeyCode::Char('T') => {
                self.tree_mode = !self.tree_mode;
                if self.tree_mode {
                    self.anomaly_mode = false;
                }
                self.mark_visible_dirty();
                self.set_status(if self.tree_mode {
                    "tree mode enabled"
                } else {
                    "flat mode enabled"
                });
            }
            KeyCode::Char('A') => {
                self.anomaly_mode = !self.anomaly_mode;
                if self.anomaly_mode {
                    self.tree_mode = false;
                }
                self.mark_visible_dirty();
                self.set_status(if self.anomaly_mode {
                    "anomaly view enabled"
                } else {
                    "anomaly view disabled"
                });
            }
            KeyCode::Char('e') => {
                self.effects_enabled = !self.effects_enabled;
                self.set_status(if self.effects_enabled {
                    "aisling effects enabled"
                } else {
                    "aisling effects disabled"
                });
            }
            KeyCode::Char('r') => {
                self.descending = !self.descending;
                self.mark_visible_dirty();
            }
            KeyCode::Char('d') => {
                self.show_details = !self.show_details;
                self.set_status(if self.show_details {
                    "detail pane enabled"
                } else {
                    "detail pane hidden"
                });
            }
            KeyCode::Char(' ') => {
                self.paused = !self.paused;
                self.set_status(if self.paused { "paused" } else { "resumed" });
            }
            KeyCode::Char('x') => {
                self.search.clear();
                self.reset_selection();
                self.mark_visible_dirty();
                self.set_status("filter cleared");
            }
            KeyCode::Char('g') => {
                self.refresh();
                self.set_status("forced refresh");
            }
            KeyCode::Char('s') => self.open_signal_palette(),
            KeyCode::Char('K') => self.prepare_signal(SignalKind::Term),
            _ => {}
        }

        false
    }

    fn handle_mouse(&mut self, mouse: MouseEvent) {
        self.mouse_pos = Some((mouse.column, mouse.row));

        match mouse.kind {
            MouseEventKind::Moved => {
                self.hovered = self.visible_row_at(mouse.column, mouse.row);
            }
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
                    self.set_selected_index(row);
                    self.hovered = Some(row);
                }
            }
            MouseEventKind::Down(MouseButton::Right) => {
                if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
                    self.set_selected_index(row);
                    self.hovered = Some(row);
                    self.open_signal_palette();
                }
            }
            MouseEventKind::Down(MouseButton::Middle) => {
                if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
                    self.set_selected_index(row);
                    self.prepare_signal(SignalKind::Term);
                }
            }
            MouseEventKind::ScrollDown => self.move_selection(3),
            MouseEventKind::ScrollUp => self.move_selection(-3),
            _ => {}
        }
    }

    fn visible_row_at(&self, x: u16, y: u16) -> Option<usize> {
        self.table_hotspot
            .row_at(x, y)
            .filter(|row| *row < self.visible.len())
    }

    fn handle_spotlight_key(&mut self, key: KeyEvent) -> bool {
        match key.code {
            KeyCode::Esc => self.spotlight = false,
            KeyCode::Enter => {
                self.spotlight = false;
                let count = self.visible_count();
                self.set_status(format!("{count} matching processes"));
            }
            KeyCode::Backspace => {
                self.search.pop();
                self.reset_selection();
                self.mark_visible_dirty();
            }
            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.search.clear();
                self.reset_selection();
                self.mark_visible_dirty();
            }
            KeyCode::Char(ch) => {
                if !key.modifiers.contains(KeyModifiers::CONTROL) {
                    if self.search.len().saturating_add(ch.len_utf8()) <= SPOTLIGHT_QUERY_LIMIT {
                        self.search.push(ch);
                        self.reset_selection();
                        self.mark_visible_dirty();
                    } else {
                        self.set_status(format!(
                            "spotlight query limited to {SPOTLIGHT_QUERY_LIMIT} bytes"
                        ));
                    }
                }
            }
            KeyCode::Down => self.move_selection(1),
            KeyCode::Up => self.move_selection(-1),
            _ => {}
        }
        false
    }

    fn handle_signal_palette_key(&mut self, key: KeyEvent) -> bool {
        match key.code {
            KeyCode::Esc => {
                self.signal_palette = None;
                self.set_status("signal palette closed");
            }
            KeyCode::Char('t') => self.prepare_signal(SignalKind::Term),
            KeyCode::Char('k') => self.prepare_signal(SignalKind::Kill),
            KeyCode::Char('s') => self.prepare_signal(SignalKind::Stop),
            KeyCode::Char('c') => self.prepare_signal(SignalKind::Continue),
            KeyCode::Char('i') => self.prepare_signal(SignalKind::Interrupt),
            KeyCode::Char('h') => self.prepare_signal(SignalKind::Hangup),
            _ => {}
        }
        false
    }

    fn handle_signal_confirm_key(&mut self, key: KeyEvent) -> bool {
        match key.code {
            KeyCode::Char('y') | KeyCode::Enter => {
                if let Some(action) = self.signal_confirm.take() {
                    self.send_signal(action);
                }
            }
            KeyCode::Char('n') | KeyCode::Esc => {
                self.signal_confirm = None;
                self.set_status("signal cancelled");
            }
            _ => {}
        }
        false
    }

    fn open_spotlight(&mut self) {
        self.spotlight = true;
        self.set_status("spotlight: type tokens, fields, or thresholds");
    }

    fn open_signal_palette(&mut self) {
        let Some(pid) = self.current_pid() else {
            self.set_status("no selected process");
            return;
        };
        self.signal_palette = Some(pid);
        self.set_status(format!("signal palette for pid {pid}"));
    }

    fn current_pid(&mut self) -> Option<u32> {
        self.rebuild_visible();
        let Some(entry) = self.visible.get(self.selected) else {
            return None;
        };
        Some(self.view.processes[entry.index].pid)
    }

    fn prepare_signal(&mut self, signal: SignalKind) {
        let Some(pid) = self.signal_palette.or_else(|| self.current_pid()) else {
            self.set_status("no selected process");
            return;
        };
        self.signal_palette = None;
        self.signal_confirm = Some(SignalAction { pid, signal });
    }

    fn send_signal(&mut self, action: SignalAction) {
        match send_signal_to_pid(action.pid, action.signal) {
            Ok(()) => {
                self.set_status(format!(
                    "sent {} to pid {}",
                    action.signal.label(),
                    action.pid
                ));
                self.refresh();
            }
            Err(error) => self.set_status(format!("signal failed: {error}")),
        }
    }

    fn set_status(&mut self, status: impl AsRef<str>) {
        self.status = truncate_to_bytes(status.as_ref(), STATUS_LIMIT);
    }

    fn set_sort(&mut self, sort: SortMode) {
        if self.sort == sort {
            self.descending = !self.descending;
        } else {
            self.sort = sort;
            self.descending = matches!(
                sort,
                SortMode::Cpu | SortMode::Mem | SortMode::Threads | SortMode::Age
            );
        }
        self.mark_visible_dirty();
    }

    fn cycle_sort(&mut self) {
        let next = match self.sort {
            SortMode::Cpu => SortMode::Mem,
            SortMode::Mem => SortMode::Pid,
            SortMode::Pid => SortMode::Name,
            SortMode::Name => SortMode::User,
            SortMode::User => SortMode::Threads,
            SortMode::Threads => SortMode::Age,
            SortMode::Age => SortMode::Cpu,
        };
        self.set_sort(next);
    }

    fn move_selection(&mut self, delta: isize) {
        let count = self.visible_count();
        if count == 0 {
            self.reset_selection();
            return;
        }
        let next = (self.selected as isize + delta).clamp(0, count.saturating_sub(1) as isize);
        self.set_selected_index(next as usize);
    }

    fn reset_selection(&mut self) {
        self.selected = 0;
        self.scroll = 0;
        self.selected_pid = None;
    }

    fn set_selected_index(&mut self, index: usize) {
        self.rebuild_visible();
        if self.visible.is_empty() {
            self.reset_selection();
            return;
        }
        self.selected = index.min(self.visible.len() - 1);
        self.selected_pid = self
            .visible
            .get(self.selected)
            .map(|entry| self.view.processes[entry.index].pid);
    }

    fn clamp_selection_to_visible(&mut self) {
        let count = self.visible.len();
        if count == 0 {
            self.reset_selection();
        } else {
            if let Some(pid) = self.selected_pid {
                if let Some(position) = self
                    .visible
                    .iter()
                    .position(|entry| self.view.processes[entry.index].pid == pid)
                {
                    self.selected = position;
                } else {
                    self.selected = self.selected.min(count - 1);
                }
            } else {
                self.selected = self.selected.min(count - 1);
            }
            self.selected_pid = self
                .visible
                .get(self.selected)
                .map(|entry| self.view.processes[entry.index].pid);
            self.scroll = self.scroll.min(count - 1);
        }
    }

    fn visible_count(&mut self) -> usize {
        self.rebuild_visible();
        self.visible.len()
    }

    fn mark_visible_dirty(&mut self) {
        self.visible_dirty = true;
    }

    fn draw_boot_loader(&self, frame: &mut Frame, label: &str) {
        frame.clear(self.base_style());
        if frame.width() < 32 || frame.height() < 8 {
            frame.text(
                0,
                0,
                "ktop loading",
                self.base_style().fg(self.aisling.accent()).bold(),
            );
            return;
        }

        self.draw_matrix_effect(frame);
        let width = frame.width().saturating_sub(10).min(72).max(28);
        let rect = Rect {
            x: frame.width().saturating_sub(width) / 2,
            y: frame.height().saturating_sub(9) / 2,
            width,
            height: 9,
        };
        let bg = Color::Rgb { r: 4, g: 10, b: 24 };
        let base = self.base_style().bg(bg);
        frame.fill_rect(rect, ' ', base);
        frame.border(
            rect,
            base.fg(self.aisling.accent()).bold(),
            Some("AISLING BOOT"),
        );
        frame.text_center(
            rect,
            rect.y + 2,
            "KTOP",
            base.fg(self.aisling.accent()).bold(),
        );
        frame.text_center(rect, rect.y + 3, label, base.fg(Aisling::soft()));

        let bar_width = rect.width.saturating_sub(8);
        let bar_x = rect.x + 4;
        let bar_y = rect.y + 5;
        frame.fill_rect(
            Rect {
                x: bar_x,
                y: bar_y,
                width: bar_width,
                height: 1,
            },
            '.',
            base.fg(Aisling::dim()),
        );
        let head = self.aisling.tick(18.0, bar_width.max(1) as usize) as u16;
        for offset in 0..bar_width.min(16) {
            let x = bar_x + (head + offset) % bar_width.max(1);
            frame.set(
                x,
                bar_y,
                loader_char(offset as usize),
                base.fg(self.aisling.loader(offset as usize)).bold(),
            );
        }

        frame.text_center(
            rect,
            rect.y + 7,
            "diff renderer armed / bounded procfs reads",
            base.fg(Aisling::dim()),
        );
    }

    fn rebuild_visible(&mut self) {
        if !self.visible_dirty {
            return;
        }

        let query = Query::parse(&self.search);
        let sort = self.sort;
        let descending = self.descending;
        let processes = &self.view.processes;
        self.visible = if self.anomaly_mode {
            build_anomaly_visible(processes, &query)
        } else if self.tree_mode {
            build_tree_visible(processes, &query, sort, descending)
        } else {
            let mut indices = processes
                .iter()
                .enumerate()
                .filter_map(|(index, process)| query.matches(process).then_some(index))
                .collect::<Vec<_>>();
            sort_process_indices(&mut indices, processes, sort, descending);
            indices
                .into_iter()
                .map(|index| VisibleProcess { index, depth: 0 })
                .collect()
        };
        self.visible_dirty = false;
        self.clamp_selection_to_visible();
    }

    fn draw(&mut self, frame: &mut Frame) {
        self.rebuild_visible();
        frame.clear(self.base_style());
        if frame.width() < 40 || frame.height() < 10 {
            frame.text(
                0,
                0,
                "ktop needs at least 40x10",
                self.base_style().fg(Aisling::warn()).bold(),
            );
            return;
        }

        self.draw_matrix_effect(frame);
        self.draw_header(frame);
        let table_y = self.draw_meters(frame);
        let detail_width = self.detail_width(frame);
        let table_width = frame.width().saturating_sub(detail_width);
        self.draw_table(frame, table_y, table_width);
        if detail_width > 0 {
            self.draw_details(frame, table_y, table_width, detail_width);
        }
        self.draw_footer(frame);
        self.draw_hover_overlay(frame);

        if self.spotlight {
            self.draw_spotlight(frame);
        }
        if let Some(pid) = self.signal_palette {
            self.draw_signal_palette(frame, pid);
        }
        if let Some(action) = self.signal_confirm {
            self.draw_signal_confirm(frame, action);
        }
    }

    fn draw_header(&self, frame: &mut Frame) {
        let title = "KTOP";
        for (index, ch) in title.chars().enumerate() {
            let glitching = self.effects_enabled && self.aisling.phase(11.0, index as f32) > 0.965;
            frame.set(
                2 + index as u16,
                0,
                if glitching { glitch_char(index) } else { ch },
                self.base_style()
                    .fg(if glitching {
                        self.aisling.glitch(index)
                    } else {
                        self.aisling.title(index)
                    })
                    .bold(),
            );
        }
        frame.text(
            7,
            0,
            "scrin / aisling process deck",
            self.base_style().fg(Aisling::soft()),
        );

        let mode = if self.descending { "desc" } else { "asc" };
        let pulse = spinner_char(self.aisling.tick(10.0, 4));
        let view_mode = if self.anomaly_mode {
            "anomaly"
        } else if self.tree_mode {
            "tree"
        } else {
            "flat"
        };
        let paused = if self.paused { " PAUSED" } else { "" };
        let right = format!(
            "{} {} {} {} | load {:.2} {:.2} {:.2} | {} procs | {} threads{}",
            pulse,
            self.sort.label(),
            mode,
            view_mode,
            self.view.load.one,
            self.view.load.five,
            self.view.load.fifteen,
            self.view.processes.len(),
            self.view.thread_count,
            paused
        );
        frame.text_right(
            0,
            0,
            frame.width(),
            &right,
            self.base_style().fg(self.aisling.accent()).bold(),
        );
    }

    fn draw_matrix_effect(&self, frame: &mut Frame) {
        if !self.effects_enabled || frame.width() < 80 || frame.height() < 16 {
            return;
        }

        let columns = (frame.width() / 18).clamp(3, 10);
        let height = frame.height().saturating_sub(4).max(1);
        for index in 0..columns {
            let x = 5 + index * 18;
            if x >= frame.width().saturating_sub(2) {
                continue;
            }
            let phase = self
                .aisling
                .phase(0.9 + index as f32 * 0.05, index as f32 * 2.1);
            let y = 2 + ((height as f32 * phase) as u16).min(height);
            let ch = matrix_char(index as usize + y as usize);
            frame.set(
                x,
                y.min(frame.height().saturating_sub(3)),
                ch,
                self.base_style()
                    .fg(self.aisling.matrix(index as usize))
                    .bold(),
            );
        }
    }

    fn detail_width(&self, frame: &Frame) -> u16 {
        if self.show_details && frame.width() >= 118 && frame.height() >= 18 {
            (frame.width() / 3).clamp(34, 46)
        } else {
            0
        }
    }

    fn draw_meters(&self, frame: &mut Frame) -> u16 {
        let width = frame.width().saturating_sub(4);
        let mut y = 2;
        let label = format!("{:5.1}% total", self.view.cpu_total);
        frame.text(2, y, "CPU", self.base_style().fg(Aisling::soft()).bold());
        frame.bar(
            Rect {
                x: 8,
                y,
                width: width.saturating_sub(6),
                height: 1,
            },
            self.view.cpu_total / 100.0,
            self.base_style()
                .bg(Aisling::heat(self.view.cpu_total / 100.0)),
            self.base_style()
                .bg(Color::Rgb {
                    r: 15,
                    g: 23,
                    b: 42,
                })
                .fg(Aisling::soft()),
            &label,
        );
        y += 1;

        let mem_ratio = percent_ratio(self.view.memory.used, self.view.memory.total);
        let mem_label = format!(
            "{} / {} ({:4.1}%, {} avail)",
            format_bytes(self.view.memory.used),
            format_bytes(self.view.memory.total),
            mem_ratio * 100.0,
            format_bytes(self.view.memory.available)
        );
        frame.text(2, y, "MEM", self.base_style().fg(Aisling::soft()).bold());
        frame.bar(
            Rect {
                x: 8,
                y,
                width: width.saturating_sub(6),
                height: 1,
            },
            mem_ratio,
            self.base_style().bg(Aisling::memory(mem_ratio)),
            self.base_style()
                .bg(Color::Rgb {
                    r: 15,
                    g: 23,
                    b: 42,
                })
                .fg(Aisling::soft()),
            &mem_label,
        );
        y += 1;

        if self.view.memory.swap_total > 0 {
            let swap_ratio = percent_ratio(self.view.memory.swap_used, self.view.memory.swap_total);
            let swap_label = format!(
                "{} / {} ({:4.1}%)",
                format_bytes(self.view.memory.swap_used),
                format_bytes(self.view.memory.swap_total),
                swap_ratio * 100.0
            );
            frame.text(2, y, "SWP", self.base_style().fg(Aisling::soft()).bold());
            frame.bar(
                Rect {
                    x: 8,
                    y,
                    width: width.saturating_sub(6),
                    height: 1,
                },
                swap_ratio,
                self.base_style().bg(Aisling::memory(swap_ratio)),
                self.base_style()
                    .bg(Color::Rgb {
                        r: 15,
                        g: 23,
                        b: 42,
                    })
                    .fg(Aisling::soft()),
                &swap_label,
            );
            y += 1;
        }

        self.draw_core_strip(frame, y);
        y += 1;

        self.draw_state_strip(frame, y);
        y += 1;

        if frame.height() >= 20 {
            self.draw_history_strip(frame, y, "CPU trend", &self.cpu_history, HistoryKind::Cpu);
            y += 1;
            self.draw_history_strip(
                frame,
                y,
                "MEM trend",
                &self.mem_history,
                HistoryKind::Memory,
            );
            y += 1;
        }

        y + 1
    }

    fn draw_core_strip(&self, frame: &mut Frame, y: u16) {
        frame.text(2, y, "Cores", self.base_style().fg(Aisling::soft()).bold());
        let mut x = 8_u16;
        let segment = 11_u16;
        for cpu in &self.view.cpus {
            if x.saturating_add(segment) >= frame.width().saturating_sub(1) {
                break;
            }
            let label = format!("{:>2}", cpu.name);
            frame.text_fit(x, y, 2, &label, self.base_style().fg(Aisling::dim()));
            frame.bar(
                Rect {
                    x: x + 3,
                    y,
                    width: 7,
                    height: 1,
                },
                cpu.usage / 100.0,
                self.base_style().bg(Aisling::heat(cpu.usage / 100.0)),
                self.base_style()
                    .bg(Color::Rgb {
                        r: 15,
                        g: 23,
                        b: 42,
                    })
                    .fg(Aisling::soft()),
                "",
            );
            x += segment;
        }
    }

    fn draw_state_strip(&self, frame: &mut Frame, y: u16) {
        let states = self.view.states;
        let text = format!(
            "R {}  S {}  D {}  T {}  Z {}  I {}  ? {}",
            states.running,
            states.sleeping,
            states.disk_sleep,
            states.stopped,
            states.zombie,
            states.idle,
            states.other
        );
        frame.text(2, y, "States", self.base_style().fg(Aisling::soft()).bold());
        frame.text_fit(
            12,
            y,
            frame.width().saturating_sub(14),
            &text,
            self.base_style().fg(Aisling::soft()),
        );
    }

    fn draw_history_strip(
        &self,
        frame: &mut Frame,
        y: u16,
        label: &str,
        history: &VecDeque<f32>,
        kind: HistoryKind,
    ) {
        let start = 12;
        let width = frame.width().saturating_sub(start + 2);
        if width == 0 {
            return;
        }
        frame.text(2, y, label, self.base_style().fg(Aisling::soft()).bold());
        draw_sparkline(frame, start, y, width, history, kind, self.base_style());
    }

    fn draw_table(&mut self, frame: &mut Frame, table_y: u16, table_width: u16) {
        let footer_rows = 2;
        let available_rows = frame
            .height()
            .saturating_sub(table_y)
            .saturating_sub(footer_rows);
        if available_rows < 2 || table_width < 24 {
            self.table_hotspot = TableHotspot::default();
            return;
        }
        self.rebuild_visible();
        let visible_len = self.visible.len();
        self.ensure_visible(available_rows.saturating_sub(1) as usize, visible_len);

        let header_style = self.base_style().fg(self.aisling.accent()).bold();
        frame.text_fit(
            1,
            table_y,
            table_width.saturating_sub(2),
            &self.table_header(table_width),
            header_style,
        );

        if visible_len == 0 {
            frame.text(
                2,
                table_y + 2,
                "No processes match the spotlight query. Try clearing tokens with Ctrl+U or x.",
                self.base_style().fg(Aisling::warn()).bold(),
            );
            return;
        }

        let rows = available_rows.saturating_sub(1) as usize;
        self.table_hotspot = TableHotspot {
            x: 0,
            y: table_y + 1,
            width: table_width,
            rows,
            scroll: self.scroll,
        };
        let end = self.scroll.saturating_add(rows).min(visible_len);
        for visible_row in self.scroll..end {
            let screen_row = visible_row - self.scroll;
            let y = table_y + 1 + screen_row as u16;
            let entry = self.visible[visible_row];
            let process = &self.view.processes[entry.index];
            let selected = self.scroll + screen_row == self.selected;
            let hovered = self.hovered == Some(self.scroll + screen_row);
            let row_bg = if selected {
                self.aisling.selected_bg()
            } else if hovered {
                Color::Rgb {
                    r: 17,
                    g: 31,
                    b: 49,
                }
            } else if screen_row % 2 == 0 {
                Color::Rgb { r: 3, g: 7, b: 18 }
            } else {
                Color::Rgb { r: 8, g: 13, b: 28 }
            };
            frame.fill_rect(
                Rect {
                    x: 0,
                    y,
                    width: table_width,
                    height: 1,
                },
                ' ',
                self.base_style().bg(row_bg),
            );
            self.draw_process_row(
                frame,
                y,
                table_width,
                process,
                entry.depth,
                row_bg,
                selected,
            );
        }
    }

    fn ensure_visible(&mut self, rows: usize, visible_len: usize) {
        if visible_len == 0 || rows == 0 {
            self.selected = 0;
            self.scroll = 0;
            return;
        }
        self.selected = self.selected.min(visible_len - 1);
        if self.selected < self.scroll {
            self.scroll = self.selected;
        } else if self.selected >= self.scroll + rows {
            self.scroll = self.selected.saturating_sub(rows - 1);
        }
    }

    fn table_header(&self, width: u16) -> String {
        let name = if self.anomaly_mode {
            "ANOMALY/NAME"
        } else if self.tree_mode {
            "TREE/NAME"
        } else {
            "NAME"
        };
        if width >= 110 {
            format!(" PID     CPU%   MEM%   RSS      VIRT     TH AGE      S USER       {name:<17}COMMAND")
        } else if width < 92 {
            format!(" PID     CPU%   MEM%   RSS      S USER       {name:<17}CMD")
        } else {
            format!(" PID     CPU%   MEM%   RSS      VIRT     TH  S USER       {name:<17}COMMAND")
        }
    }

    fn draw_process_row(
        &self,
        frame: &mut Frame,
        y: u16,
        table_width: u16,
        process: &ProcessInfo,
        depth: usize,
        bg: Color,
        selected: bool,
    ) {
        let base = self.base_style().bg(bg);
        let strong = if selected { base.bold() } else { base };
        let cpu_style = strong.fg(Aisling::heat((process.cpu / 100.0).min(1.0)));
        let mem_style = strong.fg(Aisling::memory((process.mem / 25.0).min(1.0)));
        let state_style = strong.fg(state_color(process.state));
        let mut x = 1;

        put(
            frame,
            &mut x,
            y,
            7,
            &format!("{:>6}", process.pid),
            strong.fg(Aisling::soft()),
        );
        put(
            frame,
            &mut x,
            y,
            7,
            &format!("{:>6.1}", process.cpu),
            cpu_style.bold(),
        );
        put(
            frame,
            &mut x,
            y,
            7,
            &format!("{:>6.1}", process.mem),
            mem_style,
        );
        put(
            frame,
            &mut x,
            y,
            9,
            &format!("{:>8}", format_bytes(process.rss)),
            strong.fg(Aisling::soft()),
        );

        if table_width >= 92 {
            put(
                frame,
                &mut x,
                y,
                9,
                &format!("{:>8}", format_bytes(process.vms)),
                strong.fg(Aisling::dim()),
            );
            put(
                frame,
                &mut x,
                y,
                5,
                &format!("{:>4}", process.threads),
                strong.fg(Aisling::soft()),
            );
        }

        if table_width >= 110 {
            put(
                frame,
                &mut x,
                y,
                9,
                &format!("{:<8}", format_duration_short(process.runtime)),
                strong.fg(Aisling::dim()),
            );
        }

        put(
            frame,
            &mut x,
            y,
            3,
            &process.state.to_string(),
            state_style.bold(),
        );
        put(
            frame,
            &mut x,
            y,
            11,
            process.user.as_ref(),
            strong.fg(Aisling::soft()),
        );
        let display_name = if self.anomaly_mode {
            anomaly_name(process.name.as_ref(), anomaly_score(process))
        } else if self.tree_mode {
            tree_name(process.name.as_ref(), depth)
        } else {
            process.name.as_ref().to_string()
        };
        put(
            frame,
            &mut x,
            y,
            17,
            &display_name,
            strong
                .fg(if selected {
                    self.aisling.accent()
                } else if self.anomaly_mode {
                    Aisling::burn((anomaly_score(process) / 120.0).min(1.0))
                } else {
                    Aisling::ok()
                })
                .bold(),
        );
        let remaining = table_width.saturating_sub(x).saturating_sub(1);
        frame.text_fit(
            x,
            y,
            remaining,
            process.cmd.as_ref(),
            strong.fg(Color::Rgb {
                r: 203,
                g: 213,
                b: 225,
            }),
        );
    }

    fn selected_process(&self) -> Option<&ProcessInfo> {
        self.visible
            .get(self.selected)
            .map(|entry| &self.view.processes[entry.index])
    }

    fn selected_depth(&self) -> usize {
        self.visible
            .get(self.selected)
            .map(|entry| entry.depth)
            .unwrap_or(0)
    }

    fn draw_details(&self, frame: &mut Frame, table_y: u16, x: u16, width: u16) {
        let height = frame.height().saturating_sub(table_y).saturating_sub(2);
        if width < 30 || height < 8 {
            return;
        }

        let bg = Color::Rgb { r: 5, g: 10, b: 24 };
        let rect = Rect {
            x,
            y: table_y,
            width,
            height,
        };
        let base = self.base_style().bg(bg);
        frame.fill_rect(rect, ' ', base);
        frame.border(
            rect,
            base.fg(self.aisling.accent()).bold(),
            Some("PROCESS DECK"),
        );

        let inner_x = rect.x + 2;
        let inner_width = rect.width.saturating_sub(4);
        let mut y = rect.y + 2;
        let Some(process) = self.selected_process() else {
            frame.text_fit(
                inner_x,
                y,
                inner_width,
                "No selected process",
                base.fg(Aisling::warn()).bold(),
            );
            return;
        };

        let row = format!("row {} / {}", self.selected + 1, self.visible.len());
        frame.text_fit(
            inner_x,
            y,
            inner_width,
            process.name.as_ref(),
            base.fg(self.aisling.accent()).bold(),
        );
        frame.text_right(inner_x, y, inner_width, &row, base.fg(Aisling::dim()));
        y += 2;

        frame.bar(
            Rect {
                x: inner_x,
                y,
                width: inner_width,
                height: 1,
            },
            process.cpu / 100.0,
            base.bg(Aisling::heat((process.cpu / 100.0).min(1.0))),
            base.bg(Color::Rgb {
                r: 15,
                g: 23,
                b: 42,
            })
            .fg(Aisling::soft()),
            &format!("CPU {:5.1}%", process.cpu),
        );
        y += 1;
        frame.bar(
            Rect {
                x: inner_x,
                y,
                width: inner_width,
                height: 1,
            },
            process.mem / 100.0,
            base.bg(Aisling::memory((process.mem / 15.0).min(1.0))),
            base.bg(Color::Rgb {
                r: 15,
                g: 23,
                b: 42,
            })
            .fg(Aisling::soft()),
            &format!("MEM {:5.1}%", process.mem),
        );
        y += 2;

        let detail_bottom = rect.bottom().saturating_sub(1);

        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "PID",
            &process.pid.to_string(),
            bg,
        );
        if self.anomaly_mode || is_anomaly(process) {
            self.detail_row(
                frame,
                inner_x,
                &mut y,
                inner_width,
                detail_bottom,
                "SCORE",
                &format!("{:.1}", anomaly_score(process)),
                bg,
            );
        }
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "AGE",
            &format_duration(process.runtime),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "BOOT+",
            &format_duration(process.started_after_boot),
            bg,
        );
        if self.tree_mode {
            self.detail_row(
                frame,
                inner_x,
                &mut y,
                inner_width,
                detail_bottom,
                "DEPTH",
                &self.selected_depth().to_string(),
                bg,
            );
        }
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "PPID",
            &process.ppid.to_string(),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "USER",
            process.user.as_ref(),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "UID",
            &process.uid.to_string(),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "STATE",
            &process.state.to_string(),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "THREADS",
            &process.threads.to_string(),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "RSS",
            &format_bytes(process.rss),
            bg,
        );
        self.detail_row(
            frame,
            inner_x,
            &mut y,
            inner_width,
            detail_bottom,
            "VIRT",
            &format_bytes(process.vms),
            bg,
        );

        if y + 2 < rect.bottom() {
            y += 1;
            frame.text_fit(
                inner_x,
                y,
                inner_width,
                "COMMAND",
                base.fg(Aisling::soft()).bold(),
            );
            y += 1;
            draw_wrapped(
                frame,
                inner_x,
                &mut y,
                inner_width,
                rect.bottom().saturating_sub(1),
                process.cmd.as_ref(),
                base.fg(Color::Rgb {
                    r: 203,
                    g: 213,
                    b: 225,
                }),
            );
        }
    }

    fn detail_row(
        &self,
        frame: &mut Frame,
        x: u16,
        y: &mut u16,
        width: u16,
        max_y: u16,
        label: &str,
        value: &str,
        bg: Color,
    ) {
        if width < 12 || *y >= max_y {
            return;
        }
        let base = self.base_style().bg(bg);
        frame.text_fit(x, *y, 9, label, base.fg(Aisling::dim()).bold());
        frame.text_fit(
            x + 10,
            *y,
            width.saturating_sub(10),
            value,
            base.fg(Aisling::soft()),
        );
        *y += 1;
    }

    fn draw_footer(&self, frame: &mut Frame) {
        let y = frame.height().saturating_sub(1);
        let visible = self.visible.len();
        let filter = if self.search.is_empty() {
            "filter: none".to_string()
        } else {
            format!("filter: {}", self.search)
        };
        let help = "q quit  / grep  mouse hover/right-click  T tree  A anomalies  e effects  s signals  c/m/p/n/u/t/a sort";
        frame.text_fit(
            1,
            y,
            frame.width().saturating_sub(2),
            help,
            self.base_style().fg(Aisling::dim()),
        );
        frame.text_fit(
            1,
            y.saturating_sub(1),
            frame.width().saturating_sub(2),
            &format!("{} | showing {} | {}", self.status, visible, filter),
            self.base_style().fg(Aisling::soft()),
        );
    }

    fn draw_hover_overlay(&self, frame: &mut Frame) {
        if self.spotlight || self.signal_palette.is_some() || self.signal_confirm.is_some() {
            return;
        }
        let Some(row) = self.hovered else {
            return;
        };
        let Some((mouse_x, mouse_y)) = self.mouse_pos else {
            return;
        };
        let Some(entry) = self.visible.get(row) else {
            return;
        };
        let process = &self.view.processes[entry.index];
        let width = 56_u16.min(frame.width().saturating_sub(2)).max(28);
        let height = 5_u16;
        let mut x = mouse_x.saturating_add(2);
        if x.saturating_add(width) >= frame.width() {
            x = frame.width().saturating_sub(width + 1);
        }
        let mut y = mouse_y.saturating_add(1);
        if y.saturating_add(height) >= frame.height().saturating_sub(1) {
            y = mouse_y.saturating_sub(height + 1);
        }

        let bg = Color::Rgb { r: 8, g: 18, b: 34 };
        let rect = Rect {
            x,
            y,
            width,
            height,
        };
        let base = self.base_style().bg(bg);
        frame.fill_rect(rect, ' ', base);
        frame.border(
            rect,
            base.fg(self.aisling.accent()).bold(),
            Some("HOVER ACTIONS"),
        );
        frame.text_fit(
            x + 2,
            y + 1,
            width.saturating_sub(4),
            &format!("{} pid {}", process.name.as_ref(), process.pid),
            base.fg(Color::White).bold(),
        );
        frame.text_fit(
            x + 2,
            y + 2,
            width.saturating_sub(4),
            "left select  right signals  middle TERM",
            base.fg(Aisling::soft()),
        );
        frame.text_fit(
            x + 2,
            y + 3,
            width.saturating_sub(4),
            &format!(
                "cpu {:4.1}% mem {:4.1}% score {:4.0}",
                process.cpu,
                process.mem,
                anomaly_score(process)
            ),
            base.fg(Aisling::warn()),
        );
    }

    fn draw_spotlight(&self, frame: &mut Frame) {
        let width = frame.width().saturating_sub(8).min(78).max(32);
        let height = 10_u16.min(frame.height().saturating_sub(2)).max(7);
        let rect = Rect {
            x: frame.width().saturating_sub(width) / 2,
            y: frame.height().saturating_sub(height) / 3,
            width,
            height,
        };
        frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
        frame.fill_rect(rect, ' ', self.base_style().bg(self.aisling.spotlight_bg()));
        frame.border(
            rect,
            self.base_style()
                .fg(self.aisling.accent())
                .bg(self.aisling.spotlight_bg())
                .bold(),
            Some("SPOTLIGHT GREP"),
        );

        let inner = Rect {
            x: rect.x + 2,
            y: rect.y + 2,
            width: rect.width.saturating_sub(4),
            height: rect.height.saturating_sub(4),
        };
        let query = format!(
            "> {}{}",
            self.search,
            if self.aisling.phase(4.0, 0.0) > 0.5 {
                "_"
            } else {
                " "
            }
        );
        frame.text_fit(
            inner.x,
            inner.y,
            inner.width,
            &query,
            self.base_style()
                .fg(Color::White)
                .bg(self.aisling.spotlight_bg())
                .bold(),
        );
        frame.text_fit(
            inner.x,
            inner.y + 1,
            inner.width,
            "tokens: chrome user:root cpu>20 mem>5 age>2h state:R !sleep cmd:ssh",
            self.base_style()
                .fg(Aisling::soft())
                .bg(self.aisling.spotlight_bg()),
        );
        frame.text_fit(
            inner.x,
            inner.y + 2,
            inner.width,
            &format!(
                "{} live matches. Enter keeps filter, Esc hides overlay, Ctrl+U clears.",
                self.visible.len()
            ),
            self.base_style()
                .fg(Aisling::ok())
                .bg(self.aisling.spotlight_bg()),
        );

        for (row, entry) in self.visible.iter().take(3).enumerate() {
            let process = &self.view.processes[entry.index];
            let text = format!(
                "{:>6} {:>5.1}% {:>5.1}% {:<14} {}",
                process.pid,
                process.cpu,
                process.mem,
                process.name.as_ref(),
                process.cmd.as_ref()
            );
            frame.text_fit(
                inner.x,
                inner.y + 4 + row as u16,
                inner.width,
                &text,
                self.base_style()
                    .fg(Color::Rgb {
                        r: 226,
                        g: 232,
                        b: 240,
                    })
                    .bg(self.aisling.spotlight_bg()),
            );
        }
    }

    fn draw_signal_palette(&self, frame: &mut Frame, pid: u32) {
        let width = 64_u16.min(frame.width().saturating_sub(4)).max(36);
        let rect = Rect {
            x: frame.width().saturating_sub(width) / 2,
            y: frame.height().saturating_sub(10) / 2,
            width,
            height: 10,
        };
        let bg = Color::Rgb {
            r: 12,
            g: 20,
            b: 36,
        };
        frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
        frame.fill_rect(rect, ' ', self.base_style().bg(bg));
        frame.border(
            rect,
            self.base_style().fg(self.aisling.accent()).bg(bg).bold(),
            Some("SIGNAL PALETTE"),
        );

        let style = self.base_style().bg(bg);
        frame.text_center(
            rect,
            rect.y + 2,
            &format!("Select signal for pid {pid}"),
            style.fg(Color::White).bold(),
        );
        let rows = [
            ("t", SignalKind::Term),
            ("k", SignalKind::Kill),
            ("s", SignalKind::Stop),
            ("c", SignalKind::Continue),
            ("i", SignalKind::Interrupt),
            ("h", SignalKind::Hangup),
        ];
        for (row, (key, signal)) in rows.iter().enumerate() {
            let text = format!("{}  {:<5}  {}", key, signal.label(), signal.description());
            frame.text_fit(
                rect.x + 4,
                rect.y + 4 + row as u16,
                rect.width.saturating_sub(8),
                &text,
                style.fg(if matches!(*signal, SignalKind::Kill) {
                    Aisling::danger()
                } else {
                    Aisling::soft()
                }),
            );
        }
    }

    fn draw_signal_confirm(&self, frame: &mut Frame, action: SignalAction) {
        let width = 54_u16.min(frame.width().saturating_sub(4)).max(28);
        let rect = Rect {
            x: frame.width().saturating_sub(width) / 2,
            y: frame.height().saturating_sub(7) / 2,
            width,
            height: 7,
        };
        frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
        frame.fill_rect(
            rect,
            ' ',
            self.base_style().bg(Color::Rgb {
                r: 45,
                g: 15,
                b: 24,
            }),
        );
        frame.border(
            rect,
            self.base_style()
                .fg(Aisling::danger())
                .bg(Color::Rgb {
                    r: 45,
                    g: 15,
                    b: 24,
                })
                .bold(),
            Some("CONFIRM SIGNAL"),
        );
        frame.text_center(
            rect,
            rect.y + 2,
            &format!("Send {} to pid {}?", action.signal.label(), action.pid),
            self.base_style()
                .fg(Color::White)
                .bg(Color::Rgb {
                    r: 45,
                    g: 15,
                    b: 24,
                })
                .bold(),
        );
        frame.text_center(
            rect,
            rect.y + 4,
            "y/Enter confirms, n/Esc cancels",
            self.base_style().fg(Aisling::soft()).bg(Color::Rgb {
                r: 45,
                g: 15,
                b: 24,
            }),
        );
    }
}

#[derive(Clone, Copy)]
struct VisibleProcess {
    index: usize,
    depth: usize,
}

#[derive(Clone, Copy, Default)]
struct TableHotspot {
    x: u16,
    y: u16,
    width: u16,
    rows: usize,
    scroll: usize,
}

impl TableHotspot {
    fn row_at(self, x: u16, y: u16) -> Option<usize> {
        if x < self.x || x >= self.x.saturating_add(self.width) || y < self.y {
            return None;
        }
        let row = y.saturating_sub(self.y) as usize;
        (row < self.rows).then_some(self.scroll + row)
    }
}

#[derive(Clone, Copy)]
struct SignalAction {
    pid: u32,
    signal: SignalKind,
}

#[derive(Clone, Copy)]
enum SignalKind {
    Term,
    Kill,
    Stop,
    Continue,
    Interrupt,
    Hangup,
}

impl SignalKind {
    fn raw(self) -> libc::c_int {
        match self {
            SignalKind::Term => libc::SIGTERM,
            SignalKind::Kill => libc::SIGKILL,
            SignalKind::Stop => libc::SIGSTOP,
            SignalKind::Continue => libc::SIGCONT,
            SignalKind::Interrupt => libc::SIGINT,
            SignalKind::Hangup => libc::SIGHUP,
        }
    }

    fn label(self) -> &'static str {
        match self {
            SignalKind::Term => "TERM",
            SignalKind::Kill => "KILL",
            SignalKind::Stop => "STOP",
            SignalKind::Continue => "CONT",
            SignalKind::Interrupt => "INT",
            SignalKind::Hangup => "HUP",
        }
    }

    fn description(self) -> &'static str {
        match self {
            SignalKind::Term => "request graceful shutdown",
            SignalKind::Kill => "force immediate kill",
            SignalKind::Stop => "pause execution",
            SignalKind::Continue => "resume stopped process",
            SignalKind::Interrupt => "send terminal interrupt",
            SignalKind::Hangup => "reload/hangup where supported",
        }
    }

    fn parse(input: &str) -> Option<Self> {
        match input.trim().to_ascii_uppercase().as_str() {
            "TERM" | "SIGTERM" | "15" => Some(Self::Term),
            "KILL" | "SIGKILL" | "9" => Some(Self::Kill),
            "STOP" | "SIGSTOP" | "19" => Some(Self::Stop),
            "CONT" | "SIGCONT" | "18" => Some(Self::Continue),
            "INT" | "SIGINT" | "2" => Some(Self::Interrupt),
            "HUP" | "SIGHUP" | "1" => Some(Self::Hangup),
            _ => None,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SortMode {
    Cpu,
    Mem,
    Pid,
    Name,
    User,
    Threads,
    Age,
}

impl SortMode {
    fn label(self) -> &'static str {
        match self {
            SortMode::Cpu => "cpu",
            SortMode::Mem => "mem",
            SortMode::Pid => "pid",
            SortMode::Name => "name",
            SortMode::User => "user",
            SortMode::Threads => "threads",
            SortMode::Age => "age",
        }
    }
}

#[derive(Clone, Copy)]
enum HistoryKind {
    Cpu,
    Memory,
}

fn compare_processes(left: &ProcessInfo, right: &ProcessInfo, sort: SortMode) -> Ordering {
    let ordering = match sort {
        SortMode::Cpu => left.cpu.partial_cmp(&right.cpu).unwrap_or(Ordering::Equal),
        SortMode::Mem => left.mem.partial_cmp(&right.mem).unwrap_or(Ordering::Equal),
        SortMode::Pid => left.pid.cmp(&right.pid),
        SortMode::Name => left.name.as_ref().cmp(right.name.as_ref()),
        SortMode::User => left.user.as_ref().cmp(right.user.as_ref()),
        SortMode::Threads => left.threads.cmp(&right.threads),
        SortMode::Age => left.runtime.cmp(&right.runtime),
    };

    ordering.then_with(|| left.pid.cmp(&right.pid))
}

fn sort_process_indices(
    indices: &mut [usize],
    processes: &[ProcessInfo],
    sort: SortMode,
    descending: bool,
) {
    indices.sort_by(|left, right| {
        let ordering = compare_processes(&processes[*left], &processes[*right], sort);
        if descending {
            ordering.reverse()
        } else {
            ordering
        }
    });
}

fn build_tree_visible(
    processes: &[ProcessInfo],
    query: &Query,
    sort: SortMode,
    descending: bool,
) -> Vec<VisibleProcess> {
    let mut pid_to_index = HashMap::with_capacity(processes.len());
    for (index, process) in processes.iter().enumerate() {
        pid_to_index.insert(process.pid, index);
    }

    let mut children: HashMap<u32, Vec<usize>> = HashMap::with_capacity(processes.len());
    let mut roots = Vec::new();
    for (index, process) in processes.iter().enumerate() {
        if process.ppid != 0 && pid_to_index.contains_key(&process.ppid) {
            children.entry(process.ppid).or_default().push(index);
        } else {
            roots.push(index);
        }
    }

    sort_process_indices(&mut roots, processes, sort, descending);
    for children in children.values_mut() {
        sort_process_indices(children, processes, sort, descending);
    }

    let mut visible = Vec::with_capacity(processes.len());
    let mut memo = HashMap::with_capacity(processes.len());
    let mut path = HashSet::new();
    for root in roots {
        append_tree_visible(
            root,
            0,
            processes,
            &children,
            query,
            &mut memo,
            &mut path,
            &mut visible,
        );
    }

    visible
}

fn build_anomaly_visible(processes: &[ProcessInfo], query: &Query) -> Vec<VisibleProcess> {
    let mut entries = processes
        .iter()
        .enumerate()
        .filter(|(_, process)| query.matches(process) && is_anomaly(process))
        .map(|(index, _)| index)
        .collect::<Vec<_>>();

    entries.sort_by(|left, right| {
        anomaly_score(&processes[*right])
            .partial_cmp(&anomaly_score(&processes[*left]))
            .unwrap_or(Ordering::Equal)
            .then_with(|| processes[*left].pid.cmp(&processes[*right].pid))
    });

    entries
        .into_iter()
        .map(|index| VisibleProcess { index, depth: 0 })
        .collect()
}

fn is_anomaly(process: &ProcessInfo) -> bool {
    process.cpu >= 25.0
        || process.mem >= 5.0
        || matches!(process.state, 'D' | 'Z')
        || process.threads >= 128
        || (process.runtime < 60 && process.cpu >= 10.0)
}

fn anomaly_score(process: &ProcessInfo) -> f32 {
    let state = match process.state {
        'D' => 35.0,
        'Z' => 25.0,
        'R' => 5.0,
        _ => 0.0,
    };
    let thread_pressure = if process.threads > 64 {
        ((process.threads - 64) as f32 / 8.0).min(30.0)
    } else {
        0.0
    };
    let young_hot = if process.runtime < 60 {
        process.cpu * 0.5
    } else {
        0.0
    };

    process.cpu + process.mem * 6.0 + state + thread_pressure + young_hot
}

fn append_tree_visible(
    index: usize,
    depth: usize,
    processes: &[ProcessInfo],
    children: &HashMap<u32, Vec<usize>>,
    query: &Query,
    memo: &mut HashMap<usize, bool>,
    path: &mut HashSet<usize>,
    visible: &mut Vec<VisibleProcess>,
) {
    if !query.is_empty() && !subtree_matches(index, processes, children, query, memo, path) {
        return;
    }
    if !path.insert(index) {
        return;
    }

    visible.push(VisibleProcess { index, depth });

    if let Some(child_indices) = children.get(&processes[index].pid) {
        for child in child_indices {
            append_tree_visible(
                *child,
                depth + 1,
                processes,
                children,
                query,
                memo,
                path,
                visible,
            );
        }
    }

    path.remove(&index);
}

fn subtree_matches(
    index: usize,
    processes: &[ProcessInfo],
    children: &HashMap<u32, Vec<usize>>,
    query: &Query,
    memo: &mut HashMap<usize, bool>,
    path: &mut HashSet<usize>,
) -> bool {
    if let Some(matched) = memo.get(&index) {
        return *matched;
    }
    if !path.insert(index) {
        return false;
    }

    let mut matched = query.matches(&processes[index]);
    if !matched {
        if let Some(child_indices) = children.get(&processes[index].pid) {
            matched = child_indices
                .iter()
                .any(|child| subtree_matches(*child, processes, children, query, memo, path));
        }
    }

    path.remove(&index);
    memo.insert(index, matched);
    matched
}

struct Query {
    tokens: Vec<QueryToken>,
}

struct QueryToken {
    negated: bool,
    kind: QueryKind,
}

enum QueryKind {
    Plain(String),
    Pid(String),
    Ppid(String),
    Uid(String),
    User(String),
    Name(String),
    Cmd(String),
    State(char),
    CpuGt(f32),
    CpuLt(f32),
    MemGt(f32),
    MemLt(f32),
    AgeGt(u64),
    AgeLt(u64),
}

impl Query {
    fn parse(input: &str) -> Self {
        let tokens = input
            .split_whitespace()
            .take(QUERY_TOKEN_LIMIT)
            .filter_map(|raw| {
                let (negated, token) = raw
                    .strip_prefix('!')
                    .map_or((false, raw), |token| (true, token));
                if token.is_empty() {
                    return None;
                }
                let token = truncate_to_bytes(token, QUERY_TOKEN_BYTE_LIMIT);
                Some(QueryToken {
                    negated,
                    kind: QueryKind::parse(&token),
                })
            })
            .collect();

        Self { tokens }
    }

    fn matches(&self, process: &ProcessInfo) -> bool {
        self.tokens.iter().all(|token| {
            let matched = token.kind.matches(process);
            matched != token.negated
        })
    }

    fn is_empty(&self) -> bool {
        self.tokens.is_empty()
    }
}

impl QueryKind {
    fn parse(token: &str) -> Self {
        if let Some(value) = token.strip_prefix("pid:") {
            return Self::Pid(value.to_string());
        }
        if let Some(value) = token.strip_prefix("ppid:") {
            return Self::Ppid(value.to_string());
        }
        if let Some(value) = token.strip_prefix("uid:") {
            return Self::Uid(value.to_string());
        }
        if let Some(value) = token.strip_prefix("user:") {
            return Self::User(value.to_ascii_lowercase());
        }
        if let Some(value) = token.strip_prefix("name:") {
            return Self::Name(value.to_ascii_lowercase());
        }
        if let Some(value) = token.strip_prefix("cmd:") {
            return Self::Cmd(value.to_ascii_lowercase());
        }
        if let Some(value) = token.strip_prefix("state:") {
            return Self::State(value.chars().next().unwrap_or('?').to_ascii_uppercase());
        }
        if let Some(value) = token.strip_prefix("cpu>") {
            return Self::CpuGt(value.parse::<f32>().unwrap_or(f32::MAX));
        }
        if let Some(value) = token.strip_prefix("cpu<") {
            return Self::CpuLt(value.parse::<f32>().unwrap_or(f32::MIN));
        }
        if let Some(value) = token.strip_prefix("mem>") {
            return Self::MemGt(value.parse::<f32>().unwrap_or(f32::MAX));
        }
        if let Some(value) = token.strip_prefix("mem<") {
            return Self::MemLt(value.parse::<f32>().unwrap_or(f32::MIN));
        }
        if let Some(value) = token.strip_prefix("age>") {
            return Self::AgeGt(parse_duration_filter(value).unwrap_or(u64::MAX));
        }
        if let Some(value) = token.strip_prefix("age<") {
            return Self::AgeLt(parse_duration_filter(value).unwrap_or(0));
        }

        Self::Plain(token.to_ascii_lowercase())
    }

    fn matches(&self, process: &ProcessInfo) -> bool {
        match self {
            Self::Plain(value) => process.search_text.as_ref().contains(value),
            Self::Pid(value) => process.pid.to_string().contains(value),
            Self::Ppid(value) => process.ppid.to_string().contains(value),
            Self::Uid(value) => process.uid.to_string().contains(value),
            Self::User(value) => process.user_lower.as_ref().contains(value),
            Self::Name(value) => process.name_lower.as_ref().contains(value),
            Self::Cmd(value) => process.cmd_lower.as_ref().contains(value),
            Self::State(value) => process.state.to_ascii_uppercase() == *value,
            Self::CpuGt(value) => process.cpu > *value,
            Self::CpuLt(value) => process.cpu < *value,
            Self::MemGt(value) => process.mem > *value,
            Self::MemLt(value) => process.mem < *value,
            Self::AgeGt(value) => process.runtime > *value,
            Self::AgeLt(value) => process.runtime < *value,
        }
    }
}

fn state_color(state: char) -> Color {
    match state {
        'R' => Aisling::ok(),
        'D' | 'Z' => Aisling::danger(),
        'T' | 't' => Aisling::warn(),
        'S' => Color::Rgb {
            r: 96,
            g: 165,
            b: 250,
        },
        'I' => Aisling::dim(),
        _ => Aisling::soft(),
    }
}

fn push_history(history: &mut VecDeque<f32>, value: f32) {
    if history.len() == HISTORY_LIMIT {
        history.pop_front();
    }
    history.push_back(value.clamp(0.0, 100.0));
}

fn draw_sparkline(
    frame: &mut Frame,
    x: u16,
    y: u16,
    width: u16,
    history: &VecDeque<f32>,
    kind: HistoryKind,
    base: Style,
) {
    if history.is_empty() {
        frame.text_fit(x, y, width, "collecting...", base.fg(Aisling::dim()));
        return;
    }

    let skip = history.len().saturating_sub(width as usize);
    for (offset, value) in history.iter().skip(skip).take(width as usize).enumerate() {
        let ratio = value / 100.0;
        let ch = trend_char(ratio);
        let color = match kind {
            HistoryKind::Cpu => Aisling::heat(ratio),
            HistoryKind::Memory => Aisling::memory(ratio),
        };
        frame.set(x + offset as u16, y, ch, base.fg(color).bold());
    }
}

fn trend_char(ratio: f32) -> char {
    const LEVELS: &[u8] = b" .:-=+*#%@";
    let index = ((LEVELS.len() - 1) as f32 * ratio.clamp(0.0, 1.0)).round() as usize;
    LEVELS[index] as char
}

fn matrix_char(index: usize) -> char {
    const GLYPHS: &[u8] = b"0123456789ABCDEF";
    GLYPHS[index % GLYPHS.len()] as char
}

fn loader_char(index: usize) -> char {
    const GLYPHS: &[u8] = b"=+*#%@";
    GLYPHS[index % GLYPHS.len()] as char
}

fn spinner_char(index: usize) -> char {
    const GLYPHS: &[u8] = b"|/-\\";
    GLYPHS[index % GLYPHS.len()] as char
}

fn glitch_char(index: usize) -> char {
    const GLYPHS: &[u8] = b"K70P";
    GLYPHS[index % GLYPHS.len()] as char
}

fn tree_name(name: &str, depth: usize) -> String {
    if depth == 0 {
        return name.to_string();
    }

    let indent = "  ".repeat(depth.min(7));
    format!("{indent}|- {name}")
}

fn anomaly_name(name: &str, score: f32) -> String {
    format!("!{score:>4.0} {name}")
}

fn draw_wrapped(
    frame: &mut Frame,
    x: u16,
    y: &mut u16,
    width: u16,
    max_y: u16,
    text: &str,
    style: Style,
) {
    if width == 0 {
        return;
    }

    let mut line = String::new();
    for ch in text.chars() {
        if *y >= max_y {
            return;
        }
        if line.chars().count() >= width as usize {
            frame.text_fit(x, *y, width, &line, style);
            *y += 1;
            line.clear();
        }
        line.push(ch);
    }

    if !line.is_empty() && *y < max_y {
        frame.text_fit(x, *y, width, &line, style);
        *y += 1;
    }
}

fn put(frame: &mut Frame, x: &mut u16, y: u16, width: u16, text: &str, style: Style) {
    frame.text_fit(*x, y, width, text, style);
    *x = (*x).saturating_add(width);
}

fn percent_ratio(used: u64, total: u64) -> f32 {
    if total == 0 {
        0.0
    } else {
        used as f32 / total as f32
    }
}

fn format_duration(seconds: u64) -> String {
    let days = seconds / 86_400;
    let hours = (seconds % 86_400) / 3_600;
    let minutes = (seconds % 3_600) / 60;
    let secs = seconds % 60;

    if days > 0 {
        format!("{days}d {hours:02}h {minutes:02}m")
    } else if hours > 0 {
        format!("{hours}h {minutes:02}m {secs:02}s")
    } else if minutes > 0 {
        format!("{minutes}m {secs:02}s")
    } else {
        format!("{secs}s")
    }
}

fn format_duration_short(seconds: u64) -> String {
    let days = seconds / 86_400;
    let hours = (seconds % 86_400) / 3_600;
    let minutes = (seconds % 3_600) / 60;

    if days > 0 {
        format!("{days}d{hours:02}h")
    } else if hours > 0 {
        format!("{hours}h{minutes:02}m")
    } else if minutes > 0 {
        format!("{minutes}m")
    } else {
        format!("{seconds}s")
    }
}

fn parse_duration_filter(input: &str) -> Option<u64> {
    let mut digits = String::new();
    let mut suffix = String::new();
    for ch in input.chars() {
        if ch.is_ascii_digit() {
            if !suffix.is_empty() {
                return None;
            }
            digits.push(ch);
        } else {
            suffix.push(ch.to_ascii_lowercase());
        }
    }

    let value = digits.parse::<u64>().ok()?;
    Some(match suffix.as_str() {
        "" | "s" | "sec" | "secs" => value,
        "m" | "min" | "mins" => value.saturating_mul(60),
        "h" | "hr" | "hrs" => value.saturating_mul(3_600),
        "d" | "day" | "days" => value.saturating_mul(86_400),
        _ => return None,
    })
}

fn truncate_to_bytes(input: &str, limit: usize) -> String {
    if input.len() <= limit {
        return input.to_string();
    }

    let mut end = limit;
    while end > 0 && !input.is_char_boundary(end) {
        end -= 1;
    }
    input[..end].to_string()
}

fn format_bytes(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{}{}", value as u64, UNITS[unit])
    } else if value < 10.0 {
        format!("{value:.1}{}", UNITS[unit])
    } else {
        format!("{value:.0}{}", UNITS[unit])
    }
}

fn format_byte_delta(first: u64, last: u64) -> String {
    if last >= first {
        format!("+{}", format_bytes(last - first))
    } else {
        format!("-{}", format_bytes(first - last))
    }
}

fn yes_no(value: bool) -> &'static str {
    if value {
        "yes"
    } else {
        "no"
    }
}

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

    #[test]
    fn pid_targets_are_capped() {
        let target = (1..=PID_TARGET_LIMIT + 1)
            .map(|pid| pid.to_string())
            .collect::<Vec<_>>()
            .join(",");

        let error = parse_pid_targets(&target).unwrap_err();

        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn non_pid_target_remains_query() {
        assert!(parse_pid_targets("cmd:python").unwrap().is_none());
    }

    #[test]
    fn query_parser_caps_token_count_and_size() {
        let input = (0..QUERY_TOKEN_LIMIT + 8)
            .map(|index| format!("name:{index:04}{}", "x".repeat(QUERY_TOKEN_BYTE_LIMIT * 2)))
            .collect::<Vec<_>>()
            .join(" ");

        let query = Query::parse(&input);

        assert_eq!(query.tokens.len(), QUERY_TOKEN_LIMIT);
        for token in &query.tokens {
            if let QueryKind::Name(value) = &token.kind {
                assert!(value.len() <= QUERY_TOKEN_BYTE_LIMIT);
            }
        }
    }

    #[test]
    fn truncate_to_bytes_preserves_utf8_boundaries() {
        assert_eq!(truncate_to_bytes("ééé", 3), "é");
        assert_eq!(truncate_to_bytes("abcdef", 4), "abcd");
    }

    #[test]
    fn signal_values_match_platform_constants() {
        assert_eq!(SignalKind::Term.raw(), libc::SIGTERM);
        assert_eq!(SignalKind::Kill.raw(), libc::SIGKILL);
        assert_eq!(SignalKind::Stop.raw(), libc::SIGSTOP);
        assert_eq!(SignalKind::Continue.raw(), libc::SIGCONT);
        assert_eq!(SignalKind::Interrupt.raw(), libc::SIGINT);
        assert_eq!(SignalKind::Hangup.raw(), libc::SIGHUP);
    }

    #[test]
    fn stability_check_parser_accepts_bounded_values() {
        let args = ["--stability-check", "--samples", "3", "--interval-ms", "10"]
            .into_iter()
            .map(str::to_string);

        let mode = CliMode::parse(args).unwrap();

        match mode {
            CliMode::StabilityCheck { samples, interval } => {
                assert_eq!(samples, 3);
                assert_eq!(interval, Duration::from_millis(10));
            }
            _ => panic!("expected stability check mode"),
        }
    }

    #[test]
    fn stability_check_rejects_unbounded_sample_count() {
        let args = [
            "--stability-check".to_string(),
            "--samples".to_string(),
            (STABILITY_SAMPLE_LIMIT + 1).to_string(),
        ];

        let error = match CliMode::parse(args.into_iter()) {
            Ok(_) => panic!("expected invalid sample count"),
            Err(error) => error,
        };

        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn stability_check_rejects_unbounded_interval() {
        let args = [
            "--stability-check".to_string(),
            "--interval-ms".to_string(),
            (STABILITY_INTERVAL_LIMIT_MS + 1).to_string(),
        ];

        let error = match CliMode::parse(args.into_iter()) {
            Ok(_) => panic!("expected invalid interval"),
            Err(error) => error,
        };

        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn output_limit_is_bounded() {
        let args = [
            "--grep".to_string(),
            "ktop".to_string(),
            "--limit".to_string(),
            (CLI_OUTPUT_LIMIT + 1).to_string(),
        ];

        let error = match CliMode::parse(args.into_iter()) {
            Ok(_) => panic!("expected invalid output limit"),
            Err(error) => error,
        };

        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn byte_delta_formats_direction() {
        assert_eq!(format_byte_delta(1024, 2048), "+1.0K");
        assert_eq!(format_byte_delta(2048, 1024), "-1.0K");
    }

    #[test]
    fn yes_no_formats_booleans() {
        assert_eq!(yes_no(true), "yes");
        assert_eq!(yes_no(false), "no");
    }
}
