use std::collections::HashMap;
use std::io;

use chrono::{Datelike, Duration, Local};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
    Block, Borders, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
    Table, TableState,
};
use ratatui::Terminal;

use crate::models::Stats;
use crate::repository::Repository;
use crate::theme::theme;
use crate::util::{dirs_home, format_count, format_duration_ms, shorten_path};

// ── Period selector ──────────────────────────────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Period {
    Days7,
    Days30,
    Days90,
    AllTime,
}

impl Period {
    const fn days(self) -> Option<usize> {
        match self {
            Self::Days7 => Some(7),
            Self::Days30 => Some(30),
            Self::Days90 => Some(90),
            Self::AllTime => None,
        }
    }

    const fn label(self) -> &'static str {
        match self {
            Self::Days7 => "7d",
            Self::Days30 => "30d",
            Self::Days90 => "90d",
            Self::AllTime => "All",
        }
    }

    const fn heatmap_days(self) -> usize {
        match self {
            Self::Days7 => 30,
            Self::Days30 => 90,
            Self::Days90 => 180,
            Self::AllTime => 365,
        }
    }
}

// ── Focus management ─────────────────────────────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
    Hourly,
    TopPrograms,
    TopCommands,
    TopDirs,
}

impl Focus {
    const fn next(self) -> Self {
        match self {
            Self::Hourly => Self::TopPrograms,
            Self::TopPrograms => Self::TopCommands,
            Self::TopCommands => Self::TopDirs,
            Self::TopDirs => Self::Hourly,
        }
    }

    const fn prev(self) -> Self {
        match self {
            Self::Hourly => Self::TopDirs,
            Self::TopPrograms => Self::Hourly,
            Self::TopCommands => Self::TopPrograms,
            Self::TopDirs => Self::TopCommands,
        }
    }
}

// ── App state ────────────────────────────────────────────────

struct StatsApp {
    stats: Stats,
    daily_activity: Vec<(String, u32, i64)>,
    period: Period,
    focus: Focus,
    commands_table_state: TableState,
    dirs_table_state: TableState,
    programs_table_state: TableState,
    program_groups: Vec<(String, i64)>,
    show_executor: bool,
    human_only: bool,
    top_n: usize,
    tag_id: Option<i64>,
    tag_name: Option<String>,
}

impl StatsApp {
    fn new(
        repo: &Repository,
        period: Period,
        top_n: usize,
        tag_id: Option<i64>,
        tag_name: Option<String>,
        human_only: bool,
    ) -> Self {
        let stats = repo
            .get_stats_filtered(period.days(), top_n, tag_id, human_only)
            .unwrap_or_else(|_| Stats {
                total_commands: 0,
                unique_commands: 0,
                success_count: 0,
                failure_count: 0,
                avg_duration_ms: 0,
                top_commands: Vec::new(),
                top_directories: Vec::new(),
                hourly_distribution: Vec::new(),
                executor_breakdown: Vec::new(),
                period_days: period.days(),
            });

        let daily_activity = repo
            .get_daily_activity(period.heatmap_days(), tag_id)
            .unwrap_or_default();

        let programs: Vec<&str> = stats
            .top_commands
            .iter()
            .filter_map(|(cmd, _)| cmd.split_whitespace().next())
            .collect();
        let alias_map = build_alias_map(repo, &programs);
        let program_groups = compute_program_groups(&stats.top_commands, &alias_map);

        let mut app = Self {
            stats,
            daily_activity,
            period,
            focus: Focus::TopCommands,
            commands_table_state: TableState::default(),
            dirs_table_state: TableState::default(),
            programs_table_state: TableState::default(),
            program_groups,
            show_executor: false,
            human_only,
            top_n,
            tag_id,
            tag_name,
        };

        if !app.stats.top_commands.is_empty() {
            app.commands_table_state.select(Some(0));
        }
        if !app.stats.top_directories.is_empty() {
            app.dirs_table_state.select(Some(0));
        }
        if !app.program_groups.is_empty() {
            app.programs_table_state.select(Some(0));
        }

        app
    }

    fn reload(&mut self, repo: &Repository) {
        if let Ok(s) =
            repo.get_stats_filtered(self.period.days(), self.top_n, self.tag_id, self.human_only)
        {
            self.stats = s;
        }
        if let Ok(d) = repo.get_daily_activity(self.period.heatmap_days(), self.tag_id) {
            self.daily_activity = d;
        }
        let programs: Vec<&str> = self
            .stats
            .top_commands
            .iter()
            .filter_map(|(cmd, _)| cmd.split_whitespace().next())
            .collect();
        let alias_map = build_alias_map(repo, &programs);
        self.program_groups = compute_program_groups(&self.stats.top_commands, &alias_map);
        self.commands_table_state = TableState::default();
        self.dirs_table_state = TableState::default();
        self.programs_table_state = TableState::default();
        if !self.stats.top_commands.is_empty() {
            self.commands_table_state.select(Some(0));
        }
        if !self.stats.top_directories.is_empty() {
            self.dirs_table_state.select(Some(0));
        }
        if !self.program_groups.is_empty() {
            self.programs_table_state.select(Some(0));
        }
    }

    /// Returns false to quit.
    fn handle_input(&mut self, key: KeyEvent, repo: &Repository) -> bool {
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') => return false,
            KeyCode::Char('1') => {
                self.period = Period::Days7;
                self.reload(repo);
            }
            KeyCode::Char('2') => {
                self.period = Period::Days30;
                self.reload(repo);
            }
            KeyCode::Char('3') => {
                self.period = Period::Days90;
                self.reload(repo);
            }
            KeyCode::Char('4') => {
                self.period = Period::AllTime;
                self.reload(repo);
            }
            KeyCode::Char('e') => self.show_executor = !self.show_executor,
            KeyCode::Char('h') => {
                self.human_only = !self.human_only;
                self.reload(repo);
            }
            KeyCode::Tab => {
                self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
                    self.focus.prev()
                } else {
                    self.focus.next()
                };
            }
            KeyCode::BackTab => {
                self.focus = self.focus.prev();
            }
            KeyCode::Up => self.move_selection_up(),
            KeyCode::Down => self.move_selection_down(),
            _ => {}
        }
        true
    }

    const fn move_selection_up(&mut self) {
        match self.focus {
            Focus::TopCommands => {
                if let Some(cur) = self.commands_table_state.selected() {
                    self.commands_table_state
                        .select(Some(cur.saturating_sub(1)));
                }
            }
            Focus::TopDirs => {
                if let Some(cur) = self.dirs_table_state.selected() {
                    self.dirs_table_state.select(Some(cur.saturating_sub(1)));
                }
            }
            Focus::TopPrograms => {
                if let Some(cur) = self.programs_table_state.selected() {
                    self.programs_table_state
                        .select(Some(cur.saturating_sub(1)));
                }
            }
            Focus::Hourly => {}
        }
    }

    fn move_selection_down(&mut self) {
        match self.focus {
            Focus::TopCommands => {
                let max = self.stats.top_commands.len().saturating_sub(1);
                if let Some(cur) = self.commands_table_state.selected() {
                    self.commands_table_state
                        .select(Some(cur.saturating_add(1).min(max)));
                }
            }
            Focus::TopDirs => {
                let max = self.stats.top_directories.len().saturating_sub(1);
                if let Some(cur) = self.dirs_table_state.selected() {
                    self.dirs_table_state
                        .select(Some(cur.saturating_add(1).min(max)));
                }
            }
            Focus::TopPrograms => {
                let max = self.program_groups.len().saturating_sub(1);
                if let Some(cur) = self.programs_table_state.selected() {
                    self.programs_table_state
                        .select(Some(cur.saturating_add(1).min(max)));
                }
            }
            Focus::Hourly => {}
        }
    }

    // ── Main render ──────────────────────────────────────────

    fn render(&mut self, f: &mut ratatui::Frame) {
        let size = f.area();
        let compact = size.height < 30;

        let chunks = if compact {
            Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(1), // header
                    Constraint::Length(3), // metrics
                    Constraint::Length(9), // heatmap
                    Constraint::Min(0),    // panels
                    Constraint::Length(1), // footer
                ])
                .split(size)
        } else {
            Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(1),  // header
                    Constraint::Length(3),  // metrics
                    Constraint::Length(11), // heatmap
                    Constraint::Length(4),  // sparkline
                    Constraint::Min(0),     // panels
                    Constraint::Length(1),  // footer
                ])
                .split(size)
        };

        self.render_header(f, chunks[0]);
        self.render_metrics(f, chunks[1]);
        self.render_heatmap(f, chunks[2]);

        if compact {
            self.render_panels(f, chunks[3]);
            self.render_footer(f, chunks[4]);
        } else {
            self.render_sparkline(f, chunks[3]);
            self.render_panels(f, chunks[4]);
            self.render_footer(f, chunks[5]);
        }
    }

    // ── Header ───────────────────────────────────────────────

    #[allow(clippy::cast_possible_truncation)]
    fn render_header(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();

        let mut title_spans: Vec<Span> = vec![Span::styled(
            " Suvadu Stats ",
            Style::default().fg(t.primary).add_modifier(Modifier::BOLD),
        )];

        if let Some(ref tag) = self.tag_name {
            title_spans.push(Span::styled(
                format!(" tag:{tag} "),
                Style::default()
                    .fg(Color::Black)
                    .bg(t.warning)
                    .add_modifier(Modifier::BOLD),
            ));
        }

        let periods = [
            Period::Days7,
            Period::Days30,
            Period::Days90,
            Period::AllTime,
        ];
        let mut period_spans: Vec<Span> = Vec::new();
        for (i, p) in periods.iter().enumerate() {
            period_spans.push(Span::styled(
                format!("{}", i + 1),
                Style::default().fg(t.text_muted),
            ));
            if *p == self.period {
                period_spans.push(Span::styled(
                    format!(" {} ", p.label()),
                    Style::default()
                        .bg(t.primary)
                        .fg(Color::Black)
                        .add_modifier(Modifier::BOLD),
                ));
            } else {
                period_spans.push(Span::styled(
                    format!(" {} ", p.label()),
                    Style::default().fg(t.text_muted),
                ));
            }
            period_spans.push(Span::raw(" "));
        }

        let mut spans = title_spans;
        spans.push(Span::styled("  ", Style::default()));
        spans.extend(period_spans);

        f.render_widget(Paragraph::new(Line::from(spans)), area);
    }

    // ── Metrics bar ──────────────────────────────────────────

    #[allow(clippy::cast_precision_loss)]
    fn render_metrics(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let s = &self.stats;

        let content = if self.show_executor {
            let mut spans: Vec<Span> = vec![Span::styled(
                " Executors: ",
                Style::default()
                    .fg(t.text_secondary)
                    .add_modifier(Modifier::BOLD),
            )];
            for (exec, count) in &s.executor_breakdown {
                spans.push(Span::styled(
                    format!("{exec} "),
                    Style::default().fg(t.text_secondary),
                ));
                spans.push(Span::styled(
                    format!("{}  ", format_count(*count)),
                    Style::default().fg(t.text).add_modifier(Modifier::BOLD),
                ));
            }
            Line::from(spans)
        } else {
            let success_rate = if s.total_commands > 0 {
                (s.success_count as f64 / s.total_commands as f64) * 100.0
            } else {
                0.0
            };
            let rate_color = if success_rate >= 90.0 {
                t.success
            } else if success_rate >= 70.0 {
                t.warning
            } else {
                t.error
            };
            let duration = format_duration_ms(s.avg_duration_ms);

            Line::from(vec![
                Span::styled(" Total ", Style::default().fg(t.text_secondary)),
                Span::styled(
                    format_count(s.total_commands),
                    Style::default().fg(t.text).add_modifier(Modifier::BOLD),
                ),
                Span::styled("  │  Unique ", Style::default().fg(t.text_secondary)),
                Span::styled(
                    format_count(s.unique_commands),
                    Style::default().fg(t.text).add_modifier(Modifier::BOLD),
                ),
                Span::styled("  │  Success ", Style::default().fg(t.text_secondary)),
                Span::styled(
                    format!("{success_rate:.1}%"),
                    Style::default().fg(rate_color).add_modifier(Modifier::BOLD),
                ),
                Span::styled("  │  Avg Duration ", Style::default().fg(t.text_secondary)),
                Span::styled(
                    duration,
                    Style::default().fg(t.text).add_modifier(Modifier::BOLD),
                ),
            ])
        };

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

        let paragraph = Paragraph::new(content).block(block);
        f.render_widget(paragraph, area);
    }

    // ── Contribution heatmap ─────────────────────────────────

    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
    fn render_heatmap(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let block = Block::default()
            .title(Span::styled(
                " Activity ",
                Style::default().fg(t.text_secondary),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(t.border));

        let inner = block.inner(area);
        f.render_widget(block, area);

        if inner.height < 3 || inner.width < 10 {
            return;
        }

        let colors = heatmap_colors(t);

        // Build a map from date string to count
        let mut day_counts: HashMap<String, i64> = HashMap::new();
        for (date, _dow, count) in &self.daily_activity {
            day_counts.insert(date.clone(), *count);
        }

        let max_count = day_counts.values().copied().max().unwrap_or(0).max(1);

        // Calculate grid dimensions
        let label_width: u16 = 5;
        let cell_width: u16 = 2;
        let available = inner.width.saturating_sub(label_width);
        let num_weeks = (available / cell_width).min(52) as usize;

        if num_weeks == 0 {
            return;
        }

        let today = Local::now().date_naive();
        let today_dow = today.weekday().num_days_from_sunday() as usize;

        // Show 7 day rows (show all or compact 3)
        let show_all_days = inner.height >= 9;
        let day_labels: Vec<(usize, &str)> = if show_all_days {
            vec![
                (0, "Sun"),
                (1, "Mon"),
                (2, "Tue"),
                (3, "Wed"),
                (4, "Thu"),
                (5, "Fri"),
                (6, "Sat"),
            ]
        } else {
            vec![(1, "Mon"), (3, "Wed"), (5, "Fri")]
        };

        let mut lines: Vec<Line> = Vec::new();

        for &(target_dow, label) in &day_labels {
            let mut spans: Vec<Span> = vec![Span::styled(
                format!("{label:<4} "),
                Style::default().fg(t.text_muted),
            )];

            for week_offset in (0..num_weeks).rev() {
                let total_days_back = week_offset * 7 + (7 + today_dow - target_dow) % 7;
                // For the current week (offset 0), only show days up to today
                if week_offset == 0 && target_dow > today_dow {
                    spans.push(Span::styled("  ", Style::default()));
                    continue;
                }

                let cell_date = today - Duration::days(total_days_back as i64);
                let date_str = cell_date.format("%Y-%m-%d").to_string();
                let count = day_counts.get(&date_str).copied().unwrap_or(0);
                let level = intensity_level(count, max_count);

                spans.push(Span::styled("  ", Style::default().bg(colors[level])));
            }

            lines.push(Line::from(spans));
        }

        // Add month labels at the bottom
        let mut month_spans: Vec<Span> = vec![Span::raw("     ")];
        let mut last_month = 0u32;
        for week_offset in (0..num_weeks).rev() {
            let total_days_back = week_offset * 7 + today_dow;
            let cell_date = today - Duration::days(total_days_back as i64);
            let month = cell_date.month();
            if month == last_month {
                month_spans.push(Span::raw("  "));
            } else {
                let name = month_abbrev(month);
                month_spans.push(Span::styled(
                    format!("{name:<2}"),
                    Style::default().fg(t.text_muted),
                ));
                last_month = month;
            }
        }
        if inner.height > day_labels.len() as u16 + 1 {
            lines.push(Line::from(month_spans));
        }

        let paragraph = Paragraph::new(lines);
        f.render_widget(paragraph, inner);
    }

    // ── Sparkline ────────────────────────────────────────────

    fn render_sparkline(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let data = self.build_daily_counts();

        let block = Block::default()
            .title(Span::styled(
                " Daily Trend ",
                Style::default().fg(t.text_secondary),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(t.border));

        let sparkline = Sparkline::default()
            .block(block)
            .data(data.iter().copied())
            .style(Style::default().fg(t.primary));

        f.render_widget(sparkline, area);
    }

    #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
    fn build_daily_counts(&self) -> Vec<u64> {
        let today = Local::now().date_naive();
        let num_days = self.period.heatmap_days();
        let mut day_map: HashMap<String, i64> = HashMap::new();
        for (date, _dow, count) in &self.daily_activity {
            day_map.insert(date.clone(), *count);
        }
        let mut data = Vec::with_capacity(num_days);
        for i in (0..num_days).rev() {
            let d = today - Duration::days(i as i64);
            let key = d.format("%Y-%m-%d").to_string();
            let count = day_map.get(&key).copied().unwrap_or(0);
            data.push(count.max(0) as u64);
        }
        data
    }

    // ── Bottom panels ────────────────────────────────────────

    fn render_panels(&mut self, f: &mut ratatui::Frame, area: Rect) {
        if area.width < 60 {
            match self.focus {
                Focus::Hourly => self.render_hourly(f, area),
                Focus::TopPrograms => self.render_top_programs(f, area),
                Focus::TopCommands => self.render_top_commands(f, area),
                Focus::TopDirs => self.render_top_dirs(f, area),
            }
            return;
        }

        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(20),
                Constraint::Percentage(20),
                Constraint::Percentage(30),
                Constraint::Percentage(30),
            ])
            .split(area);

        self.render_hourly(f, cols[0]);
        self.render_top_programs(f, cols[1]);
        self.render_top_commands(f, cols[2]);
        self.render_top_dirs(f, cols[3]);
    }

    // ── Hourly distribution ──────────────────────────────────

    #[allow(
        clippy::cast_precision_loss,
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss
    )]
    fn render_hourly(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let focused = self.focus == Focus::Hourly;
        let border_color = if focused { t.border_focus } else { t.border };

        let block = Block::default()
            .title(Span::styled(
                " Hourly ",
                Style::default().fg(if focused { t.primary } else { t.text_secondary }),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_color));

        let inner = block.inner(area);
        f.render_widget(block, area);

        if inner.height < 1 || inner.width < 8 {
            return;
        }

        let mut hourly: [i64; 24] = [0; 24];
        for &(hour, count) in &self.stats.hourly_distribution {
            let h = hour as usize;
            if h < 24 {
                hourly[h] = count;
            }
        }
        let max_count = hourly.iter().copied().max().unwrap_or(1).max(1);
        let total: i64 = hourly.iter().sum();
        // Reserve space: "HH " (3) + bar + " XX%" (4)
        let bar_width = inner.width.saturating_sub(8) as usize;

        let available_rows = inner.height as usize;
        let step = if available_rows >= 24 {
            1
        } else {
            24 / available_rows.max(1)
        };

        let mut lines: Vec<Line> = Vec::new();
        for h in (0..24).step_by(step.max(1)) {
            if lines.len() >= available_rows {
                break;
            }
            let count = hourly[h];
            let bar_len = ((count as f64 / max_count as f64) * bar_width as f64).round() as usize;
            let bar: String = "█".repeat(bar_len);
            let pct = if total > 0 {
                (count as f64 / total as f64) * 100.0
            } else {
                0.0
            };

            lines.push(Line::from(vec![
                Span::styled(format!("{h:02} "), Style::default().fg(t.text_muted)),
                Span::styled(bar, Style::default().fg(t.primary)),
                Span::styled(
                    format!(" {pct:>2.0}%"),
                    Style::default().fg(t.text).add_modifier(Modifier::BOLD),
                ),
            ]));
        }

        f.render_widget(Paragraph::new(lines), inner);
    }

    // ── Top programs table ────────────────────────────────────

    #[allow(clippy::cast_possible_truncation)]
    fn render_top_programs(&mut self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let focused = self.focus == Focus::TopPrograms;
        let border_color = if focused { t.border_focus } else { t.border };

        let block = Block::default()
            .title(Span::styled(
                " Top Programs ",
                Style::default().fg(if focused { t.primary } else { t.text_secondary }),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_color));

        let inner = block.inner(area);

        let rows: Vec<Row> = self
            .program_groups
            .iter()
            .enumerate()
            .map(|(i, (prog, count))| {
                let prog_display = truncate_str(prog, inner.width.saturating_sub(12) as usize);
                Row::new(vec![
                    format!("{:>2}", i + 1),
                    prog_display,
                    format_count(*count),
                ])
            })
            .collect();

        let widths = [
            Constraint::Length(3),
            Constraint::Min(5),
            Constraint::Length(7),
        ];

        let table = Table::new(rows, widths)
            .block(block)
            .row_highlight_style(Style::default().bg(t.selection_bg).fg(t.selection_fg))
            .highlight_symbol(" > ");

        f.render_stateful_widget(table, area, &mut self.programs_table_state);

        if self.program_groups.len() > inner.height as usize && focused {
            let mut scrollbar_state = ScrollbarState::new(self.program_groups.len())
                .position(self.programs_table_state.selected().unwrap_or(0));
            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .thumb_style(Style::default().fg(t.primary_dim));
            f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
        }
    }

    // ── Top commands table ───────────────────────────────────

    #[allow(clippy::cast_possible_truncation)]
    fn render_top_commands(&mut self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let focused = self.focus == Focus::TopCommands;
        let border_color = if focused { t.border_focus } else { t.border };

        let block = Block::default()
            .title(Span::styled(
                " Top Commands ",
                Style::default().fg(if focused { t.primary } else { t.text_secondary }),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_color));

        let inner = block.inner(area);

        let rows: Vec<Row> = self
            .stats
            .top_commands
            .iter()
            .enumerate()
            .map(|(i, (cmd, count))| {
                let cmd_display = truncate_str(cmd, inner.width.saturating_sub(12) as usize);
                Row::new(vec![
                    format!("{:>2}", i + 1),
                    cmd_display,
                    format_count(*count),
                ])
            })
            .collect();

        let widths = [
            Constraint::Length(3),
            Constraint::Min(5),
            Constraint::Length(7),
        ];

        let table = Table::new(rows, widths)
            .block(block)
            .row_highlight_style(Style::default().bg(t.selection_bg).fg(t.selection_fg))
            .highlight_symbol(" > ");

        f.render_stateful_widget(table, area, &mut self.commands_table_state);

        if self.stats.top_commands.len() > inner.height as usize && focused {
            let mut scrollbar_state = ScrollbarState::new(self.stats.top_commands.len())
                .position(self.commands_table_state.selected().unwrap_or(0));
            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .thumb_style(Style::default().fg(t.primary_dim));
            f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
        }
    }

    // ── Top directories table ────────────────────────────────

    #[allow(clippy::cast_possible_truncation)]
    fn render_top_dirs(&mut self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let focused = self.focus == Focus::TopDirs;
        let border_color = if focused { t.border_focus } else { t.border };

        let block = Block::default()
            .title(Span::styled(
                " Top Directories ",
                Style::default().fg(if focused { t.primary } else { t.text_secondary }),
            ))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_color));

        let inner = block.inner(area);
        let home = dirs_home();

        let rows: Vec<Row> = self
            .stats
            .top_directories
            .iter()
            .enumerate()
            .map(|(i, (dir, count))| {
                let display = shorten_path(dir, &home);
                let display = truncate_str(&display, inner.width.saturating_sub(12) as usize);
                Row::new(vec![format!("{:>2}", i + 1), display, format_count(*count)])
            })
            .collect();

        let widths = [
            Constraint::Length(3),
            Constraint::Min(5),
            Constraint::Length(7),
        ];

        let table = Table::new(rows, widths)
            .block(block)
            .row_highlight_style(Style::default().bg(t.selection_bg).fg(t.selection_fg))
            .highlight_symbol(" > ");

        f.render_stateful_widget(table, area, &mut self.dirs_table_state);

        if self.stats.top_directories.len() > inner.height as usize && focused {
            let mut scrollbar_state = ScrollbarState::new(self.stats.top_directories.len())
                .position(self.dirs_table_state.selected().unwrap_or(0));
            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .thumb_style(Style::default().fg(t.primary_dim));
            f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
        }
    }

    // ── Footer ───────────────────────────────────────────────

    fn render_footer(&self, f: &mut ratatui::Frame, area: Rect) {
        let t = theme();
        let badge_key = Style::default().bg(t.badge_bg).fg(t.text);
        let badge_label = Style::default().fg(t.text_secondary);

        let spans = vec![
            Span::styled(" q/Esc ", badge_key),
            Span::styled(" Quit  ", badge_label),
            Span::styled(" 1 ", badge_key),
            Span::styled(" 7d  ", badge_label),
            Span::styled(" 2 ", badge_key),
            Span::styled(" 30d  ", badge_label),
            Span::styled(" 3 ", badge_key),
            Span::styled(" 90d  ", badge_label),
            Span::styled(" 4 ", badge_key),
            Span::styled(" All  ", badge_label),
            Span::styled(" Tab ", badge_key),
            Span::styled(" Focus  ", badge_label),
            Span::styled(" e ", badge_key),
            Span::styled(
                if self.show_executor {
                    " Summary  "
                } else {
                    " Executors  "
                },
                badge_label,
            ),
        ];

        f.render_widget(Paragraph::new(Line::from(spans)), area);
    }
}

// ── Public entry point ───────────────────────────────────────

pub fn run_stats_ui<B: Backend>(
    terminal: &mut Terminal<B>,
    repo: &Repository,
    initial_days: Option<usize>,
    top_n: usize,
    tag_id: Option<i64>,
    tag_name: Option<&str>,
    human_only: bool,
) -> io::Result<()>
where
    io::Error: From<B::Error>,
{
    let period = match initial_days {
        Some(d) if d <= 7 => Period::Days7,
        Some(d) if d <= 30 => Period::Days30,
        Some(d) if d <= 90 => Period::Days90,
        _ => Period::AllTime,
    };

    let mut app = StatsApp::new(
        repo,
        period,
        top_n,
        tag_id,
        tag_name.map(String::from),
        human_only,
    );

    loop {
        terminal.draw(|f| app.render(f))?;

        if let Event::Key(key) = event::read()? {
            if key.kind != KeyEventKind::Press {
                continue;
            }
            if !app.handle_input(key, repo) {
                return Ok(());
            }
        }
    }
}

// ── Helpers ──────────────────────────────────────────────────

const fn heatmap_colors(t: &crate::theme::Theme) -> [Color; 5] {
    [
        t.bg_elevated,
        t.heatmap_low,
        t.heatmap_mid,
        t.primary_dim,
        t.primary,
    ]
}

#[allow(clippy::cast_precision_loss)]
fn intensity_level(count: i64, max: i64) -> usize {
    if count == 0 || max == 0 {
        return 0;
    }
    let ratio = count as f64 / max as f64;
    if ratio <= 0.25 {
        1
    } else if ratio <= 0.50 {
        2
    } else if ratio <= 0.75 {
        3
    } else {
        4
    }
}

const fn month_abbrev(month: u32) -> &'static str {
    match month {
        1 => "Ja",
        2 => "Fe",
        3 => "Mr",
        4 => "Ap",
        5 => "Ma",
        6 => "Jn",
        7 => "Jl",
        8 => "Au",
        9 => "Se",
        10 => "Oc",
        11 => "No",
        12 => "De",
        _ => "  ",
    }
}

fn truncate_str(s: &str, max_len: usize) -> String {
    crate::util::truncate_str(s, max_len, "...")
}

fn compute_program_groups(
    top_commands: &[(String, i64)],
    alias_map: &HashMap<String, String>,
) -> Vec<(String, i64)> {
    let mut groups: HashMap<String, i64> = HashMap::new();
    for (cmd, count) in top_commands {
        let program = cmd.split_whitespace().next().unwrap_or(cmd);
        let resolved = alias_map
            .get(program)
            .map_or(program, std::string::String::as_str);
        *groups.entry(resolved.to_string()).or_insert(0) += count;
    }
    let mut sorted: Vec<(String, i64)> = groups.into_iter().collect();
    sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
    sorted
}

/// Build a map of `alias_name` -> `resolved_program` from suvadu aliases + shell aliases.
fn build_alias_map(repo: &Repository, programs: &[&str]) -> HashMap<String, String> {
    let mut map = HashMap::new();

    // Layer 1: suvadu alias table (fast DB lookup)
    if let Ok(aliases) = repo.list_aliases() {
        for alias in aliases {
            if let Some(prog) = alias.command.split_whitespace().next() {
                map.insert(alias.name.clone(), prog.to_string());
            }
        }
    }

    // Layer 2: shell `type` check for programs not resolved by suvadu aliases
    let unresolved: Vec<&str> = programs
        .iter()
        .filter(|p| !map.contains_key(**p))
        .copied()
        .collect();

    if !unresolved.is_empty() {
        if let Some(shell_aliases) = detect_shell_aliases(&unresolved) {
            map.extend(shell_aliases);
        }
    }

    map
}

/// Run a single shell invocation to detect which programs are shell aliases
/// and resolve them to their underlying program name.
fn detect_shell_aliases(programs: &[&str]) -> Option<HashMap<String, String>> {
    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());

    // Build a single command: type prog1; type prog2; ...
    let type_cmds: Vec<String> = programs
        .iter()
        .map(|p| format!("type {p} 2>/dev/null"))
        .collect();
    let script = type_cmds.join("; ");

    let output = std::process::Command::new(&shell)
        .args(["-ic", &script])
        .output()
        .ok()?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    Some(parse_type_output(&stdout))
}

/// Parse the output of shell `type` commands to extract alias mappings.
///
/// Handles:
///   zsh:  "gst is an alias for git status"
///   bash: "gst is aliased to \`git status'"
///         "gst is aliased to 'git status'"
fn parse_type_output(output: &str) -> HashMap<String, String> {
    let mut map = HashMap::new();
    for line in output.lines() {
        let line = line.trim();
        // zsh format: "NAME is an alias for COMMAND"
        if let Some(idx) = line.find(" is an alias for ") {
            let name = &line[..idx];
            let command = &line[idx + " is an alias for ".len()..];
            if let Some(prog) = command.split_whitespace().next() {
                map.insert(name.to_string(), prog.to_string());
            }
            continue;
        }
        // bash format: "NAME is aliased to `COMMAND'" or "NAME is aliased to 'COMMAND'"
        if let Some(idx) = line.find(" is aliased to ") {
            let name = &line[..idx];
            let raw = &line[idx + " is aliased to ".len()..];
            // Strip surrounding quotes/backticks
            let command = raw
                .trim_start_matches('`')
                .trim_start_matches('\'')
                .trim_end_matches('\'');
            if let Some(prog) = command.split_whitespace().next() {
                map.insert(name.to_string(), prog.to_string());
            }
        }
    }
    map
}

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

    #[test]
    fn intensity_level_zero_count() {
        assert_eq!(intensity_level(0, 100), 0);
    }

    #[test]
    fn intensity_level_zero_max() {
        assert_eq!(intensity_level(50, 0), 0);
    }

    #[test]
    fn intensity_level_low() {
        assert_eq!(intensity_level(20, 100), 1);
    }

    #[test]
    fn intensity_level_medium_low() {
        assert_eq!(intensity_level(50, 100), 2);
    }

    #[test]
    fn intensity_level_medium_high() {
        assert_eq!(intensity_level(75, 100), 3);
    }

    #[test]
    fn intensity_level_high() {
        assert_eq!(intensity_level(90, 100), 4);
    }

    #[test]
    fn intensity_level_exact_boundaries() {
        // 25% boundary
        assert_eq!(intensity_level(25, 100), 1);
        // 50% boundary
        assert_eq!(intensity_level(50, 100), 2);
        // 75% boundary
        assert_eq!(intensity_level(75, 100), 3);
        // 76% -> level 4
        assert_eq!(intensity_level(76, 100), 4);
    }

    #[test]
    fn intensity_level_equal_count_and_max() {
        assert_eq!(intensity_level(100, 100), 4);
    }

    #[test]
    fn month_abbrev_all_months() {
        assert_eq!(month_abbrev(1), "Ja");
        assert_eq!(month_abbrev(2), "Fe");
        assert_eq!(month_abbrev(3), "Mr");
        assert_eq!(month_abbrev(4), "Ap");
        assert_eq!(month_abbrev(5), "Ma");
        assert_eq!(month_abbrev(6), "Jn");
        assert_eq!(month_abbrev(7), "Jl");
        assert_eq!(month_abbrev(8), "Au");
        assert_eq!(month_abbrev(9), "Se");
        assert_eq!(month_abbrev(10), "Oc");
        assert_eq!(month_abbrev(11), "No");
        assert_eq!(month_abbrev(12), "De");
    }

    #[test]
    fn month_abbrev_out_of_range() {
        assert_eq!(month_abbrev(0), "  ");
        assert_eq!(month_abbrev(13), "  ");
    }

    #[test]
    fn compute_program_groups_empty() {
        let result = compute_program_groups(&[], &HashMap::new());
        assert!(result.is_empty());
    }

    #[test]
    fn compute_program_groups_single_program() {
        let commands = vec![("git status".to_string(), 5), ("git push".to_string(), 3)];
        let groups = compute_program_groups(&commands, &HashMap::new());
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].0, "git");
        assert_eq!(groups[0].1, 8);
    }

    #[test]
    fn compute_program_groups_multiple_programs() {
        let commands = vec![
            ("cargo build".to_string(), 10),
            ("git status".to_string(), 5),
            ("cargo test".to_string(), 8),
            ("ls -la".to_string(), 3),
        ];
        let groups = compute_program_groups(&commands, &HashMap::new());
        assert_eq!(groups[0].0, "cargo");
        assert_eq!(groups[0].1, 18);
        assert_eq!(groups[1].0, "git");
        assert_eq!(groups[1].1, 5);
        assert_eq!(groups[2].0, "ls");
        assert_eq!(groups[2].1, 3);
    }

    #[test]
    fn compute_program_groups_single_word_command() {
        let commands = vec![("ls".to_string(), 10)];
        let groups = compute_program_groups(&commands, &HashMap::new());
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].0, "ls");
        assert_eq!(groups[0].1, 10);
    }

    #[test]
    fn test_period_days() {
        assert_eq!(Period::Days7.days(), Some(7));
        assert_eq!(Period::Days30.days(), Some(30));
        assert_eq!(Period::Days90.days(), Some(90));
        assert_eq!(Period::AllTime.days(), None);
    }

    #[test]
    fn test_period_labels() {
        assert_eq!(Period::Days7.label(), "7d");
        assert_eq!(Period::Days30.label(), "30d");
        assert_eq!(Period::Days90.label(), "90d");
        assert_eq!(Period::AllTime.label(), "All");
    }

    #[test]
    fn test_period_heatmap_days() {
        assert_eq!(Period::Days7.heatmap_days(), 30);
        assert_eq!(Period::Days30.heatmap_days(), 90);
        assert_eq!(Period::Days90.heatmap_days(), 180);
        assert_eq!(Period::AllTime.heatmap_days(), 365);
    }

    #[test]
    fn test_focus_next_cycle() {
        let f = Focus::Hourly;
        let f = f.next();
        assert_eq!(f, Focus::TopPrograms);
        let f = f.next();
        assert_eq!(f, Focus::TopCommands);
        let f = f.next();
        assert_eq!(f, Focus::TopDirs);
        let f = f.next();
        assert_eq!(f, Focus::Hourly); // wraps
    }

    #[test]
    fn test_focus_prev_cycle() {
        let f = Focus::Hourly;
        let f = f.prev();
        assert_eq!(f, Focus::TopDirs);
        let f = f.prev();
        assert_eq!(f, Focus::TopCommands);
        let f = f.prev();
        assert_eq!(f, Focus::TopPrograms);
        let f = f.prev();
        assert_eq!(f, Focus::Hourly); // wraps
    }

    #[test]
    fn test_stats_app_initial_state() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let app = StatsApp::new(&repo, Period::Days30, 10, None, None, false);

        assert_eq!(app.period, Period::Days30);
        assert_eq!(app.focus, Focus::TopCommands);
        assert!(!app.show_executor);
        assert_eq!(app.top_n, 10);
        assert!(app.tag_id.is_none());
        assert!(app.tag_name.is_none());
    }

    #[test]
    fn test_stats_app_focus_via_handle_input() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        assert_eq!(app.focus, Focus::TopCommands);

        // Tab cycles focus forward
        let cont = app.handle_input(KeyEvent::from(KeyCode::Tab), &repo);
        assert!(cont);
        assert_eq!(app.focus, Focus::TopDirs);

        let cont = app.handle_input(KeyEvent::from(KeyCode::Tab), &repo);
        assert!(cont);
        assert_eq!(app.focus, Focus::Hourly);

        let cont = app.handle_input(KeyEvent::from(KeyCode::Tab), &repo);
        assert!(cont);
        assert_eq!(app.focus, Focus::TopPrograms);

        let cont = app.handle_input(KeyEvent::from(KeyCode::Tab), &repo);
        assert!(cont);
        assert_eq!(app.focus, Focus::TopCommands); // wraps

        // BackTab cycles focus backward
        let cont = app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert!(cont);
        assert_eq!(app.focus, Focus::TopPrograms);
    }

    #[test]
    fn test_stats_app_period_change() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        assert_eq!(app.period, Period::Days7);

        app.handle_input(KeyEvent::from(KeyCode::Char('2')), &repo);
        assert_eq!(app.period, Period::Days30);

        app.handle_input(KeyEvent::from(KeyCode::Char('3')), &repo);
        assert_eq!(app.period, Period::Days90);

        app.handle_input(KeyEvent::from(KeyCode::Char('4')), &repo);
        assert_eq!(app.period, Period::AllTime);

        app.handle_input(KeyEvent::from(KeyCode::Char('1')), &repo);
        assert_eq!(app.period, Period::Days7);
    }

    #[test]
    fn test_stats_app_toggle_executor() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        assert!(!app.show_executor);

        app.handle_input(KeyEvent::from(KeyCode::Char('e')), &repo);
        assert!(app.show_executor);

        app.handle_input(KeyEvent::from(KeyCode::Char('e')), &repo);
        assert!(!app.show_executor);
    }

    #[test]
    fn test_stats_app_quit() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        let cont = app.handle_input(KeyEvent::from(KeyCode::Char('q')), &repo);
        assert!(!cont); // false = quit

        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        let cont = app.handle_input(KeyEvent::from(KeyCode::Esc), &repo);
        assert!(!cont);
    }

    #[test]
    fn test_stats_app_selection_movement() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        // Insert a session first (entries have a FK on session_id)
        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        // Insert some commands so the tables are non-empty
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "git status".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 300,
            now - 200,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "cargo build".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "ls -la".to_string(),
            "/home".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);

        // Focus is on TopCommands by default; table should have selection at 0
        assert_eq!(app.focus, Focus::TopCommands);
        assert_eq!(app.commands_table_state.selected(), Some(0));

        // Move down
        app.handle_input(KeyEvent::from(KeyCode::Down), &repo);
        assert_eq!(app.commands_table_state.selected(), Some(1));

        // Move up back to 0
        app.handle_input(KeyEvent::from(KeyCode::Up), &repo);
        assert_eq!(app.commands_table_state.selected(), Some(0));

        // Move up at 0 stays at 0 (saturating)
        app.handle_input(KeyEvent::from(KeyCode::Up), &repo);
        assert_eq!(app.commands_table_state.selected(), Some(0));
    }

    #[test]
    fn test_stats_app_selection_on_different_panels() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        repo.insert_entry(&Entry::new(
            sid.clone(),
            "git status".to_string(),
            "/tmp/a".to_string(),
            Some(0),
            now - 300,
            now - 200,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "cargo test".to_string(),
            "/tmp/b".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);

        // Switch to TopDirs
        app.focus = Focus::TopDirs;
        assert_eq!(app.dirs_table_state.selected(), Some(0));

        app.handle_input(KeyEvent::from(KeyCode::Down), &repo);
        assert_eq!(app.dirs_table_state.selected(), Some(1));

        // Switch to TopPrograms
        app.focus = Focus::TopPrograms;
        assert_eq!(app.programs_table_state.selected(), Some(0));

        app.handle_input(KeyEvent::from(KeyCode::Down), &repo);
        assert_eq!(app.programs_table_state.selected(), Some(1));

        // Hourly focus: Up/Down have no effect (no selection state for hourly)
        app.focus = Focus::Hourly;
        app.handle_input(KeyEvent::from(KeyCode::Down), &repo);
        app.handle_input(KeyEvent::from(KeyCode::Up), &repo);
        // No assertion needed - just confirming no panic
    }

    #[test]
    fn test_build_daily_counts() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        let counts = app.build_daily_counts();
        // heatmap_days for Days7 = 30
        assert_eq!(counts.len(), 30);
        // With an empty repo, all counts should be 0
        assert!(counts.iter().all(|&c| c == 0));
    }

    #[test]
    fn test_stats_app_with_tag() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let app = StatsApp::new(
            &repo,
            Period::Days30,
            5,
            Some(42),
            Some("work".to_string()),
            false,
        );

        assert_eq!(app.tag_id, Some(42));
        assert_eq!(app.tag_name, Some("work".to_string()));
        assert_eq!(app.top_n, 5);
    }

    // ── 1. intensity_level edge cases ───────────────────────────

    #[test]
    fn intensity_level_count_one_max_one() {
        // count == max, ratio = 1.0 => level 4
        assert_eq!(intensity_level(1, 1), 4);
    }

    #[test]
    fn intensity_level_large_values() {
        // 999999 / 1000000 = 0.999999 => level 4
        assert_eq!(intensity_level(999_999, 1_000_000), 4);
    }

    #[test]
    fn intensity_level_boundary_26_percent() {
        // 26/100 = 0.26 => above 0.25, so level 2
        assert_eq!(intensity_level(26, 100), 2);
        // 25/100 = 0.25 => exactly 0.25, so level 1 (ratio <= 0.25)
        assert_eq!(intensity_level(25, 100), 1);
    }

    #[test]
    fn intensity_level_negative_count() {
        // Negative count should still work: -5 != 0 and max != 0,
        // ratio = -0.05 which is <= 0.25 => level 1
        assert_eq!(intensity_level(-5, 100), 1);
    }

    // ── 2. compute_program_groups edge cases ────────────────────

    #[test]
    fn compute_program_groups_empty_string_commands() {
        let commands = vec![("".to_string(), 5)];
        let groups = compute_program_groups(&commands, &HashMap::new());
        // split_whitespace().next() on "" returns None, so unwrap_or(cmd) gives ""
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].0, "");
        assert_eq!(groups[0].1, 5);
    }

    #[test]
    fn compute_program_groups_whitespace_only_commands() {
        let commands = vec![("   ".to_string(), 3)];
        let groups = compute_program_groups(&commands, &HashMap::new());
        // split_whitespace().next() on "   " returns None, unwrap_or(cmd) gives "   "
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].0, "   ");
        assert_eq!(groups[0].1, 3);
    }

    #[test]
    fn compute_program_groups_descending_order() {
        let commands = vec![
            ("alpha run".to_string(), 2),
            ("beta test".to_string(), 10),
            ("gamma check".to_string(), 5),
            ("alpha build".to_string(), 3),
        ];
        let groups = compute_program_groups(&commands, &HashMap::new());
        // alpha: 2+3=5, beta: 10, gamma: 5
        // Sorted descending: beta(10), then alpha(5) or gamma(5) (same count, order may vary)
        assert_eq!(groups[0].0, "beta");
        assert_eq!(groups[0].1, 10);
        // The two with count 5 come next
        assert!(groups[1].1 >= groups[2].1);
        assert_eq!(groups.len(), 3);
    }

    // ── alias resolution tests ────────────────────────────────

    #[test]
    fn compute_program_groups_with_alias_map() {
        let mut alias_map = HashMap::new();
        alias_map.insert("gst".to_string(), "git".to_string());
        alias_map.insert("gco".to_string(), "git".to_string());

        let commands = vec![
            ("gst".to_string(), 30),
            ("gco main".to_string(), 25),
            ("git push".to_string(), 60),
            ("docker compose up".to_string(), 40),
        ];
        let groups = compute_program_groups(&commands, &alias_map);

        // gst(30) + gco(25) + git(60) = git(115), docker(40)
        assert_eq!(groups[0].0, "git");
        assert_eq!(groups[0].1, 115);
        assert_eq!(groups[1].0, "docker");
        assert_eq!(groups[1].1, 40);
        assert_eq!(groups.len(), 2);
    }

    #[test]
    fn compute_program_groups_alias_no_match_stays() {
        let mut alias_map = HashMap::new();
        alias_map.insert("gst".to_string(), "git".to_string());

        let commands = vec![("unknown_cmd".to_string(), 5), ("gst".to_string(), 10)];
        let groups = compute_program_groups(&commands, &alias_map);

        assert_eq!(groups.len(), 2);
        // git: 10 (from gst), unknown_cmd: 5
        assert_eq!(groups[0].0, "git");
        assert_eq!(groups[0].1, 10);
        assert_eq!(groups[1].0, "unknown_cmd");
        assert_eq!(groups[1].1, 5);
    }

    #[test]
    fn parse_type_output_zsh_format() {
        let output = "gst is an alias for git status\ngco is an alias for git checkout\n";
        let map = parse_type_output(output);
        assert_eq!(map.get("gst").unwrap(), "git");
        assert_eq!(map.get("gco").unwrap(), "git");
        assert_eq!(map.len(), 2);
    }

    #[test]
    fn parse_type_output_bash_format() {
        let output = "gst is aliased to `git status'\ngco is aliased to 'git checkout'\n";
        let map = parse_type_output(output);
        assert_eq!(map.get("gst").unwrap(), "git");
        assert_eq!(map.get("gco").unwrap(), "git");
        assert_eq!(map.len(), 2);
    }

    #[test]
    fn parse_type_output_non_alias_lines_ignored() {
        let output = "git is /usr/bin/git\nls is /bin/ls\ngst is an alias for git status\n";
        let map = parse_type_output(output);
        assert_eq!(map.len(), 1);
        assert_eq!(map.get("gst").unwrap(), "git");
    }

    #[test]
    fn parse_type_output_empty() {
        let map = parse_type_output("");
        assert!(map.is_empty());
    }

    // ── 3. build_daily_counts with data ─────────────────────────

    #[test]
    fn test_build_daily_counts_with_data() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        // Insert entries spread across today and yesterday
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "cmd1".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 1000,
            now - 500,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "cmd2".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 500,
            now - 100,
        ))
        .unwrap();

        let app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        let counts = app.build_daily_counts();

        // heatmap_days for Days7 = 30
        assert_eq!(counts.len(), 30);
        // The last element is today, and we inserted entries today, so it should be > 0
        assert!(*counts.last().unwrap() > 0);
    }

    #[test]
    fn test_build_daily_counts_all_periods() {
        let (_dir, repo) = crate::test_utils::test_repo();

        let app7 = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        assert_eq!(app7.build_daily_counts().len(), 30);

        let app30 = StatsApp::new(&repo, Period::Days30, 10, None, None, false);
        assert_eq!(app30.build_daily_counts().len(), 90);

        let app90 = StatsApp::new(&repo, Period::Days90, 10, None, None, false);
        assert_eq!(app90.build_daily_counts().len(), 180);

        let app_all = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        assert_eq!(app_all.build_daily_counts().len(), 365);
    }

    // ── 4. move_selection_down boundary clamping ────────────────

    #[test]
    fn test_move_selection_down_clamps_top_commands() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        // Insert 2 distinct commands
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "cmd_a".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "cmd_b".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        app.focus = Focus::TopCommands;

        // Move down past the last item
        app.move_selection_down(); // 0 -> 1
        app.move_selection_down(); // 1 -> clamped to 1
        app.move_selection_down(); // still 1
        assert_eq!(app.commands_table_state.selected(), Some(1));
    }

    #[test]
    fn test_move_selection_down_clamps_top_dirs() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        // Insert entries with 2 distinct directories
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "ls".to_string(),
            "/dir_a".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "ls".to_string(),
            "/dir_b".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        app.focus = Focus::TopDirs;

        app.move_selection_down(); // 0 -> 1
        app.move_selection_down(); // clamped to 1
        app.move_selection_down(); // still 1
        assert_eq!(app.dirs_table_state.selected(), Some(1));
    }

    #[test]
    fn test_move_selection_down_clamps_top_programs() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        // Insert entries with 2 distinct programs
        repo.insert_entry(&Entry::new(
            sid.clone(),
            "git status".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "cargo build".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        app.focus = Focus::TopPrograms;

        app.move_selection_down(); // 0 -> 1
        app.move_selection_down(); // clamped to 1
        app.move_selection_down(); // still 1
        assert_eq!(app.programs_table_state.selected(), Some(1));
    }

    // ── 5. move_selection_up at zero for TopDirs and TopPrograms ─

    #[test]
    fn test_move_selection_up_at_zero_top_dirs() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        repo.insert_entry(&Entry::new(
            sid,
            "ls".to_string(),
            "/some/dir".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        app.focus = Focus::TopDirs;
        assert_eq!(app.dirs_table_state.selected(), Some(0));

        // Move up at 0 should stay at 0 (saturating)
        app.move_selection_up();
        assert_eq!(app.dirs_table_state.selected(), Some(0));

        app.move_selection_up();
        assert_eq!(app.dirs_table_state.selected(), Some(0));
    }

    #[test]
    fn test_move_selection_up_at_zero_top_programs() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        repo.insert_entry(&Entry::new(
            sid,
            "git status".to_string(),
            "/tmp".to_string(),
            Some(0),
            now - 100,
            now - 50,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        app.focus = Focus::TopPrograms;
        assert_eq!(app.programs_table_state.selected(), Some(0));

        // Move up at 0 should stay at 0 (saturating)
        app.move_selection_up();
        assert_eq!(app.programs_table_state.selected(), Some(0));

        app.move_selection_up();
        assert_eq!(app.programs_table_state.selected(), Some(0));
    }

    // ── 6. handle_input BackTab from specific focus ─────────────

    #[test]
    fn test_handle_input_backtab_full_cycle() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);

        // Start at TopCommands (default)
        assert_eq!(app.focus, Focus::TopCommands);

        // BackTab: TopCommands -> TopPrograms
        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::TopPrograms);

        // BackTab: TopPrograms -> Hourly
        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::Hourly);

        // BackTab: Hourly -> TopDirs
        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::TopDirs);

        // BackTab: TopDirs -> TopCommands (wraps)
        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::TopCommands);
    }

    #[test]
    fn test_handle_input_backtab_from_top_dirs() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        app.focus = Focus::TopDirs;

        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::TopCommands);
    }

    #[test]
    fn test_handle_input_backtab_from_hourly() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        app.focus = Focus::Hourly;

        app.handle_input(KeyEvent::from(KeyCode::BackTab), &repo);
        assert_eq!(app.focus, Focus::TopDirs);
    }

    // ── 7. handle_input unknown key does nothing ────────────────

    #[test]
    fn test_handle_input_unknown_key_no_state_change() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days30, 10, None, None, false);

        let focus_before = app.focus;
        let period_before = app.period;
        let show_executor_before = app.show_executor;
        let commands_sel = app.commands_table_state.selected();
        let dirs_sel = app.dirs_table_state.selected();
        let programs_sel = app.programs_table_state.selected();

        // Press an unrecognized key (e.g., 'z')
        let cont = app.handle_input(KeyEvent::from(KeyCode::Char('z')), &repo);
        assert!(cont); // should not quit

        assert_eq!(app.focus, focus_before);
        assert_eq!(app.period, period_before);
        assert_eq!(app.show_executor, show_executor_before);
        assert_eq!(app.commands_table_state.selected(), commands_sel);
        assert_eq!(app.dirs_table_state.selected(), dirs_sel);
        assert_eq!(app.programs_table_state.selected(), programs_sel);
    }

    #[test]
    fn test_handle_input_function_key_no_state_change() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::Days30, 10, None, None, false);

        let focus_before = app.focus;
        let period_before = app.period;

        let cont = app.handle_input(KeyEvent::from(KeyCode::F(5)), &repo);
        assert!(cont);
        assert_eq!(app.focus, focus_before);
        assert_eq!(app.period, period_before);
    }

    // ── 8. success_rate calculation and color thresholds ────────

    #[test]
    fn test_success_rate_calculation_high() {
        // 95 out of 100 commands successful => 95.0% => >= 90% (success)
        let total: i64 = 100;
        let success: i64 = 95;
        let rate = (success as f64 / total as f64) * 100.0;
        assert!((rate - 95.0).abs() < f64::EPSILON);
        assert!(rate >= 90.0);
    }

    #[test]
    fn test_success_rate_calculation_warning_zone() {
        // 75 out of 100 => 75.0% => >= 70% but < 90% (warning)
        let total: i64 = 100;
        let success: i64 = 75;
        let rate = (success as f64 / total as f64) * 100.0;
        assert!((rate - 75.0).abs() < f64::EPSILON);
        assert!(rate >= 70.0);
        assert!(rate < 90.0);
    }

    #[test]
    fn test_success_rate_calculation_error_zone() {
        // 50 out of 100 => 50.0% => < 70% (error)
        let total: i64 = 100;
        let success: i64 = 50;
        let rate = (success as f64 / total as f64) * 100.0;
        assert!((rate - 50.0).abs() < f64::EPSILON);
        assert!(rate < 70.0);
    }

    #[test]
    fn test_success_rate_zero_total() {
        // 0 total => rate should be 0.0
        let total: i64 = 0;
        let rate: f64 = if total > 0 { 100.0 } else { 0.0 };
        assert!((rate - 0.0_f64).abs() < f64::EPSILON);
    }

    #[test]
    fn test_success_rate_boundary_exactly_90() {
        let total: i64 = 100;
        let success: i64 = 90;
        let rate = (success as f64 / total as f64) * 100.0;
        assert!((rate - 90.0).abs() < f64::EPSILON);
        // >= 90.0 means success color
        assert!(rate >= 90.0);
    }

    #[test]
    fn test_success_rate_boundary_exactly_70() {
        let total: i64 = 100;
        let success: i64 = 70;
        let rate = (success as f64 / total as f64) * 100.0;
        assert!((rate - 70.0).abs() < f64::EPSILON);
        // >= 70.0 but < 90.0 means warning color
        assert!(rate >= 70.0);
        assert!(rate < 90.0);
    }

    #[test]
    fn test_success_rate_boundary_69() {
        let total: i64 = 100;
        let success: i64 = 69;
        let rate = (success as f64 / total as f64) * 100.0;
        // < 70.0 means error color
        assert!(rate < 70.0);
    }

    // ── 9. Period mapping (matching run_stats_ui logic) ─────────

    #[test]
    fn test_period_mapping_from_initial_days() {
        // Replicate the period mapping logic from run_stats_ui
        fn map_period(initial_days: Option<usize>) -> Period {
            match initial_days {
                Some(d) if d <= 7 => Period::Days7,
                Some(d) if d <= 30 => Period::Days30,
                Some(d) if d <= 90 => Period::Days90,
                _ => Period::AllTime,
            }
        }

        assert_eq!(map_period(Some(5)), Period::Days7);
        assert_eq!(map_period(Some(7)), Period::Days7);
        assert_eq!(map_period(Some(15)), Period::Days30);
        assert_eq!(map_period(Some(30)), Period::Days30);
        assert_eq!(map_period(Some(60)), Period::Days90);
        assert_eq!(map_period(Some(90)), Period::Days90);
        assert_eq!(map_period(None), Period::AllTime);
        assert_eq!(map_period(Some(365)), Period::AllTime);
    }

    #[test]
    fn test_period_mapping_boundary_values() {
        fn map_period(initial_days: Option<usize>) -> Period {
            match initial_days {
                Some(d) if d <= 7 => Period::Days7,
                Some(d) if d <= 30 => Period::Days30,
                Some(d) if d <= 90 => Period::Days90,
                _ => Period::AllTime,
            }
        }

        // Exact boundaries
        assert_eq!(map_period(Some(1)), Period::Days7);
        assert_eq!(map_period(Some(7)), Period::Days7);
        assert_eq!(map_period(Some(8)), Period::Days30);
        assert_eq!(map_period(Some(30)), Period::Days30);
        assert_eq!(map_period(Some(31)), Period::Days90);
        assert_eq!(map_period(Some(90)), Period::Days90);
        assert_eq!(map_period(Some(91)), Period::AllTime);
    }

    #[test]
    fn test_period_mapping_via_stats_app_new() {
        // Verify that creating StatsApp with different periods works
        let (_dir, repo) = crate::test_utils::test_repo();

        let app = StatsApp::new(&repo, Period::Days7, 10, None, None, false);
        assert_eq!(app.period, Period::Days7);

        let app = StatsApp::new(&repo, Period::Days90, 10, None, None, false);
        assert_eq!(app.period, Period::Days90);

        let app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);
        assert_eq!(app.period, Period::AllTime);
    }

    // ── 10. reload resets table selections ──────────────────────

    #[test]
    fn test_reload_resets_table_selections_with_data() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        repo.insert_entry(&Entry::new(
            sid.clone(),
            "git status".to_string(),
            "/home/user".to_string(),
            Some(0),
            now - 300,
            now - 200,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "cargo build".to_string(),
            "/home/project".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);

        // Move selections away from 0
        app.focus = Focus::TopCommands;
        app.move_selection_down();
        assert_eq!(app.commands_table_state.selected(), Some(1));

        app.focus = Focus::TopDirs;
        app.move_selection_down();
        assert_eq!(app.dirs_table_state.selected(), Some(1));

        app.focus = Focus::TopPrograms;
        app.move_selection_down();
        assert_eq!(app.programs_table_state.selected(), Some(1));

        // Reload should reset all selections to Some(0)
        app.reload(&repo);

        assert_eq!(app.commands_table_state.selected(), Some(0));
        assert_eq!(app.dirs_table_state.selected(), Some(0));
        assert_eq!(app.programs_table_state.selected(), Some(0));
    }

    #[test]
    fn test_reload_empty_repo_no_selection() {
        let (_dir, repo) = crate::test_utils::test_repo();
        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);

        // With no data, selections should be None
        app.reload(&repo);

        assert_eq!(app.commands_table_state.selected(), None);
        assert_eq!(app.dirs_table_state.selected(), None);
        assert_eq!(app.programs_table_state.selected(), None);
    }

    #[test]
    fn test_reload_via_period_change_resets_selections() {
        use crate::models::{Entry, Session};

        let (_dir, repo) = crate::test_utils::test_repo();
        let now = chrono::Local::now().timestamp_millis();

        let session = Session::new("test-host".to_string(), now);
        repo.insert_session(&session).unwrap();
        let sid = session.id.clone();

        repo.insert_entry(&Entry::new(
            sid.clone(),
            "git log".to_string(),
            "/workspace".to_string(),
            Some(0),
            now - 300,
            now - 200,
        ))
        .unwrap();
        repo.insert_entry(&Entry::new(
            sid,
            "make test".to_string(),
            "/workspace".to_string(),
            Some(0),
            now - 200,
            now - 100,
        ))
        .unwrap();

        let mut app = StatsApp::new(&repo, Period::AllTime, 10, None, None, false);

        // Move command selection down
        app.focus = Focus::TopCommands;
        app.move_selection_down();
        assert_eq!(app.commands_table_state.selected(), Some(1));

        // Changing period triggers reload, which resets selections
        app.handle_input(KeyEvent::from(KeyCode::Char('1')), &repo);
        assert_eq!(app.period, Period::Days7);
        assert_eq!(app.commands_table_state.selected(), Some(0));
    }

    // ── heatmap_colors tests ─────────────────────────────────────

    #[test]
    fn test_heatmap_colors_returns_five_elements() {
        let colors = heatmap_colors(theme());
        assert_eq!(colors.len(), 5);
    }

    #[test]
    fn test_heatmap_colors_matches_theme() {
        let t = theme();
        let colors = heatmap_colors(t);
        assert_eq!(colors[0], t.bg_elevated);
        assert_eq!(colors[1], t.heatmap_low);
        assert_eq!(colors[2], t.heatmap_mid);
        assert_eq!(colors[3], t.primary_dim);
        assert_eq!(colors[4], t.primary);
    }

    #[test]
    fn test_heatmap_colors_ascending_intensity() {
        let colors = heatmap_colors(theme());
        // Verify they are all valid Color values and not all identical,
        // confirming the array maps from background through increasing intensity.
        let all_same = colors.windows(2).all(|w| w[0] == w[1]);
        assert!(!all_same, "heatmap colors should not all be the same");
    }
}
