//! Modal `?` help overlay listing every keybinding grouped by
//! category. Layout is fully derived from `DEFAULT_BINDINGS` —
//! each binding row carries its own `categories` list, and the
//! renderer walks every [`Category`] in order, collecting rows
//! whose binding lands in that section.
//!
//! Bindings come from `App::bindings_for` so config overrides flow
//! through automatically.

use std::collections::BTreeMap;

use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
use ratatui::Frame;

use crate::theme::Palette;
use crate::tui::app::App;
use crate::tui::keybindings::{Action, Binding, Category, Focus};

/// Render the overlay. Caller is responsible for only invoking
/// this when `app.show_help` is true. Layout is static — the active
/// focus does not change which sections appear or where.
pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, palette: &Palette) {
  let rect = centred(
    area,
    area.width.saturating_sub(4).min(130),
    area.height.saturating_sub(4).max(20),
  );
  frame.render_widget(Clear, rect);
  crate::tui::render::paint_theme_bg(frame, rect, palette);

  // Close chip carries both keys that dismiss the overlay:
  //   - `Esc` is hardcoded modal-dismiss in `events::handle_key`
  //     (not an Action), so it always works regardless of the
  //     `toggle_help` binding; we hardcode it here too.
  //   - The `toggle_help` action's live key (default `?`) is
  //     surfaced second so config overrides flow through.
  let toggle_key = app
    .bindings_for(Focus::List)
    .iter()
    .find(|b| b.action == Action::ToggleHelp)
    .map(|b| b.label.to_string())
    .unwrap_or_else(|| "?".to_string());

  // Compute layout + scroll bounds first so the title can advertise
  // scrolling only when the content actually overflows the viewport.
  let block_for_inner = Block::default()
    .borders(Borders::ALL)
    .padding(Padding::new(2, 2, 1, 1));
  let inner = block_for_inner.inner(rect);
  let sections = build_sections(app);
  // Single-column layout when the overlay is narrow (≤ 80 cells of
  // usable width) — column-balancing on a 25-cell strip just truncates
  // everything. Wider terminals get the canonical 3-column packing.
  let n_cols: usize = if inner.width >= 80 { 3 } else { 1 };
  let columns = balance_into_columns(&sections, n_cols);
  let tallest = columns
    .iter()
    .map(|col| column_height(col))
    .max()
    .unwrap_or(0) as u16;
  let max_scroll = tallest.saturating_sub(inner.height);
  let scroll_y = app.help_scroll.min(max_scroll);

  let close_chip = if max_scroll > 0 {
    format!("Esc/{toggle_key}:close · j/k:scroll")
  } else {
    format!("Esc/{toggle_key}:close")
  };
  let block = Block::default()
    .title(Line::from(vec![
      Span::styled(
        " Help ",
        Style::default()
          .fg(palette.panel_title)
          .add_modifier(Modifier::BOLD),
      ),
      Span::styled(format!("· {close_chip} "), palette.muted_style()),
    ]))
    .borders(Borders::ALL)
    .border_style(palette.accent_style())
    .padding(Padding::new(2, 2, 1, 1));
  frame.render_widget(block, rect);

  let constraints: Vec<Constraint> = (0..n_cols)
    .map(|_| Constraint::Ratio(1, n_cols as u32))
    .collect();
  let cols = Layout::default()
    .direction(Direction::Horizontal)
    .constraints(constraints)
    .split(inner);

  for (idx, col_sections) in columns.iter().enumerate() {
    let lines = render_column(col_sections, palette);
    frame.render_widget(
      Paragraph::new(lines)
        .wrap(Wrap { trim: false })
        .scroll((scroll_y, 0)),
      cols[idx],
    );
  }
}

/// Total line count a column would render at: title + one line per
/// row + one blank trailer, summed across every section.
fn column_height(sections: &[&Section]) -> usize {
  sections.iter().map(|s| s.rows.len() + 2).sum()
}

/// One help-overlay section: a category title plus the resolved
/// rows (each a `(keys, description)` pair) drawn under it.
#[derive(Debug)]
struct Section {
  title: &'static str,
  rows: Vec<(String, String)>,
}

/// Walk `Category::ALL` in order; for each, collect every binding
/// whose `categories` contains that category. Bindings collapse to
/// one row only when they share BOTH the same action and the same
/// effective description — so `↑` and `k` merge into `↑,k → up/prev`
/// (same action `MoveUp`, same description), but `Esc` and `M` stay
/// separate (both `FocusList`, but Esc reads "back/cancel/clear/exit
/// edit" while M reads "models list"). Sections with no rows drop.
fn build_sections(app: &App) -> Vec<Section> {
  let mut sections: Vec<Section> = Vec::with_capacity(Category::ALL.len());
  let flat: Vec<&Binding> = collect_flat(app);

  for &category in Category::ALL {
    // Group bindings landing in this category by (action, effective
    // description), preserving first-seen order. Same-action bindings
    // with diverging descriptions land in separate rows.
    let mut row_order: Vec<(Action, String)> = Vec::new();
    let mut by_row: BTreeMap<usize, Vec<&Binding>> = BTreeMap::new();
    for b in &flat {
      if !b.categories.contains(&category) {
        continue;
      }
      let description = b
        .action
        .description_for(category)
        .map(|s| s.to_string())
        .unwrap_or_else(|| b.description().to_string());
      let key = (b.action, description);
      let pos = row_order.iter().position(|k| *k == key);
      let idx = match pos {
        Some(i) => i,
        None => {
          row_order.push(key);
          row_order.len() - 1
        }
      };
      by_row.entry(idx).or_default().push(b);
    }

    let mut rows: Vec<(String, String)> = Vec::with_capacity(row_order.len());
    for (idx, (_, description)) in row_order.iter().enumerate() {
      let group = &by_row[&idx];
      let keys = group.iter().map(|b| b.label).collect::<Vec<_>>().join(",");
      rows.push((keys, description.clone()));
    }
    if rows.is_empty() {
      continue;
    }
    sections.push(Section {
      title: category.label(),
      rows,
    });
  }
  sections.push(legend_section());
  sections
}

/// Static legend explaining glyphs used in panel labels. Currently
/// the only entry is the `*` suffix on the Host panel's RAM row,
/// which flags unified memory (Apple Metal / AMD UMA APUs share
/// the same physical pool with VRAM, so the RAM total subtracts
/// the GPU-allocated bytes to avoid double-counting).
fn legend_section() -> Section {
  Section {
    title: "Legend",
    rows: vec![(
      "RAM*".to_string(),
      "unified memory (shared with VRAM)".to_string(),
    )],
  }
}

/// Snapshot the full keymap as a flat slice, deduplicating bindings
/// that appear under multiple focuses (the per-focus cache stores
/// the same binding once per focus).
fn collect_flat(app: &App) -> Vec<&Binding> {
  let mut seen: Vec<(
    crossterm::event::KeyCode,
    crossterm::event::KeyModifiers,
    Action,
  )> = Vec::new();
  let mut out: Vec<&Binding> = Vec::new();
  for focus in [
    Focus::List,
    Focus::Filter,
    Focus::RightPane,
    Focus::ChatInput,
    Focus::EmbedInput,
    Focus::RerankInput,
    Focus::ConfirmPopup,
    Focus::HfDialog,
  ] {
    for b in app.bindings_for(focus) {
      let key = (b.key, b.mods, b.action);
      if !seen.contains(&key) {
        seen.push(key);
        out.push(b);
      }
    }
  }
  out
}

/// Greedy column-packing: distribute `sections` into `n` columns so
/// the columns are roughly the same length (one line per row plus
/// title + blank trailer). Picks the shortest column at each step.
fn balance_into_columns(sections: &[Section], n: usize) -> Vec<Vec<&Section>> {
  let mut columns: Vec<Vec<&Section>> = vec![Vec::new(); n];
  let mut lengths: Vec<usize> = vec![0; n];
  for section in sections {
    let target = lengths
      .iter()
      .enumerate()
      .min_by_key(|(_, &l)| l)
      .map(|(i, _)| i)
      .unwrap_or(0);
    columns[target].push(section);
    // 1 title row + 1 row per binding + 1 trailing blank.
    lengths[target] += section.rows.len() + 2;
  }
  columns
}

fn render_column(sections: &[&Section], palette: &Palette) -> Vec<Line<'static>> {
  let mut out: Vec<Line<'static>> = Vec::new();
  for section in sections {
    out.push(Line::from(Span::styled(
      section.title.to_string(),
      Style::default()
        .fg(palette.accent)
        .add_modifier(Modifier::BOLD),
    )));
    for (keys, description) in &section.rows {
      out.push(render_binding_line(keys, description, palette));
    }
    out.push(Line::default());
  }
  out
}

fn render_binding_line(keys: &str, description: &str, palette: &Palette) -> Line<'static> {
  Line::from(vec![
    Span::styled("  ".to_string(), Style::default()),
    Span::styled(
      format!("{keys:<12}"),
      Style::default()
        .fg(palette.label)
        .add_modifier(Modifier::BOLD),
    ),
    Span::styled("  ".to_string(), Style::default()),
    Span::styled(description.to_string(), palette.text_style()),
  ])
}

/// Centre a `w × h` rect within `area`, clamping to the available
/// space so a narrow terminal still sees the overlay (just snug).
fn centred(area: Rect, w: u16, h: u16) -> Rect {
  let w = w.min(area.width.saturating_sub(2));
  let h = h.min(area.height.saturating_sub(2));
  let x = area.x + (area.width.saturating_sub(w)) / 2;
  let y = area.y + (area.height.saturating_sub(h)) / 2;
  Rect::new(x, y, w, h)
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::tui::app::AppOptions;
  use ratatui::backend::TestBackend;
  use ratatui::Terminal;

  fn render_to_string(width: u16, height: u16, app: &App) -> String {
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).expect("test backend");
    terminal
      .draw(|frame| {
        let palette = crate::theme::palette_for(crate::theme::ThemeName::Macchiato);
        render(frame, frame.area(), app, palette);
      })
      .expect("draw");
    let buffer = terminal.backend().buffer();
    let mut s = String::new();
    for y in 0..buffer.area.height {
      for x in 0..buffer.area.width {
        s.push_str(buffer[(x, y)].symbol());
      }
      s.push('\n');
    }
    s
  }

  #[test]
  fn overlay_renders_global_section() {
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 36, &app);
    assert!(
      frame.contains("General"),
      "missing General section:\n{frame}"
    );
    assert!(frame.contains("quit"), "missing quit row:\n{frame}");
    assert!(frame.contains("help"), "missing help row:\n{frame}");
  }

  #[test]
  fn overlay_lists_motion_only_under_global() {
    // Motion (`↑/↓/k/j`) lives in Global as a single merged row —
    // category-specific overrides for Logs/Settings have been
    // retired. The Logs/Settings sections still exist with their
    // own non-motion chords.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(
      frame.contains("General"),
      "General section missing:\n{frame}"
    );
    assert!(
      frame.contains("up/prev") && frame.contains("down/next"),
      "Global motion descriptions missing:\n{frame}"
    );
    // Rerank's NextField/PrevField bindings still surface "prev field"
    // in the Rerank section — assert only the Logs override is gone.
    assert!(
      !frame.contains("scroll up") && !frame.contains("scroll down"),
      "Logs motion overrides should be gone:\n{frame}"
    );
  }

  #[test]
  fn overlay_merges_aliased_chords() {
    // `c` and `y` both bind to YankCurl with the same description; the
    // Models section should surface them on one row as `c,y` rather
    // than two separate rows.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(
      frame.contains("c,y"),
      "aliased yank chord missing:\n{frame}"
    );
  }

  #[test]
  fn overlay_shows_ram_star_legend() {
    // The Host panel marks unified-memory machines with `RAM*` — the
    // help overlay's Legend section is the only place that explains
    // what the star means.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(frame.contains("Legend"), "Legend section missing:\n{frame}");
    assert!(
      frame.contains("RAM*") && frame.contains("unified memory"),
      "RAM* legend row missing:\n{frame}"
    );
  }

  #[test]
  fn overlay_shows_hf_pull_under_global() {
    // Shift+P (`OpenHfDialog`) is categorised as Global since it
    // works from any non-input focus.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(frame.contains("pull"), "pull row missing:\n{frame}");
  }

  #[test]
  fn esc_and_shift_m_render_as_separate_rows() {
    // Esc (RIGHT_PANE → FocusList) and Shift+M (NAV → FocusList) share
    // the same action but carry different descriptions — the help
    // overlay must keep them on separate rows so the Esc line reads
    // "cancel/clear/back", not "models list".
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(
      frame.contains("cancel/clear/back"),
      "Esc consolidated description missing:\n{frame}"
    );
    assert!(
      frame.contains("models list"),
      "Shift+M models list row missing:\n{frame}"
    );
  }

  #[test]
  fn overlay_merges_chat_embed_rerank_into_single_section() {
    // The three input tabs collapse into one `Chat/Embed/Rerank`
    // section. Enter reads "submit" (no per-tab override); the rerank
    // ↑/↓ field-cycle rows and the chat-specific `send chat` text
    // are hidden so the section stays a focused list of shared chords.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(
      frame.contains("Chat/Embed/Rerank"),
      "merged section title missing:\n{frame}"
    );
    assert!(
      !frame.contains("Chat tab") && !frame.contains("Embed tab") && !frame.contains("Rerank tab"),
      "old per-tab headings should be gone:\n{frame}"
    );
    assert!(
      !frame.contains("send chat") && !frame.contains("send embed") && !frame.contains("query/add"),
      "per-tab Enter overrides should be gone:\n{frame}"
    );
    assert!(
      !frame.contains("next field") && !frame.contains("prev field"),
      "rerank field-cycle rows should not surface here:\n{frame}"
    );
  }

  #[test]
  fn overlay_lists_hf_dialog_stage_chords() {
    // `o` (sort), `n` (next page), `p` (prev page) live in events.rs
    // but surface in the help overlay via display-only bindings.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 40, &app);
    assert!(
      frame.contains("HF pull dialog"),
      "HF section missing:\n{frame}"
    );
    assert!(
      frame.contains("cycle sort order"),
      "sort row missing:\n{frame}"
    );
    assert!(
      frame.contains("next page"),
      "next-page row missing:\n{frame}"
    );
    assert!(
      frame.contains("prev page"),
      "prev-page row missing:\n{frame}"
    );
  }

  #[test]
  fn overlay_falls_back_to_single_column_on_narrow_widths() {
    // Below the 80-cell threshold the renderer collapses to a single
    // column so each row prints in full — three thin columns would
    // truncate every description.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(60, 36, &app);
    // General section still surfaces; the Esc row fits on one line
    // because the column is now full-width.
    assert!(frame.contains("General"), "General missing:\n{frame}");
    assert!(
      frame.contains("cancel/clear/back"),
      "Esc row missing under single-column:\n{frame}"
    );
  }

  #[test]
  fn overlay_merges_vim_aliases_into_their_semantic_rows() {
    // Vim aliases sit on the same row as the canonical chord they
    // alias, never in a separate "Vim" section. Tab/l/gt share the
    // NextFocus row in General; PgDn/Ctrl+F share the page-down row
    // in Models list; g/Home/0 share the GoTop row; G/End/$ share
    // the GoBottom row. The merge keeps muscle-memory users covered
    // without bloating the layout with a redundant section.
    let app = App::new(AppOptions::default());
    let frame = render_to_string(140, 60, &app);
    // Ctrl-letter labels diverge by platform: `ctrl_label!` emits
    // `⌃f`/`⌃b` on macOS and `Ctrl+f`/`Ctrl+b` elsewhere. Match the
    // live convention so this test stays green on every CI lane.
    // `e,i` covers the vim `i` alias for `EnterEdit` — must appear
    // as a merged row, not a separate one. `Ctrl+u` is intentionally
    // NOT asserted here. Vim's Ctrl+U is half-page-up, but llamastash
    // collapses it to full PgUp (same as Ctrl+B), so listing both in
    // help next to PgUp would just duplicate the row. It stays
    // dispatchable (muscle memory keeps working) but hidden via
    // NO_CAT in `keybindings.rs`.
    let ctrl_f = crate::ctrl_label!("f");
    let ctrl_b = crate::ctrl_label!("b");
    for needle in [ctrl_f, ctrl_b, "0", "$", "gt", "gT", "e,i"] {
      assert!(
        frame.contains(needle),
        "vim chord {needle:?} missing from help overlay:\n{frame}"
      );
    }
    assert!(
      !frame.contains("Vim aliases"),
      "vim chords must NOT live in a dedicated section — they belong on their semantic row:\n{frame}"
    );
  }

  #[test]
  fn overlay_scrolls_when_help_scroll_advanced() {
    // A short terminal can't fit every section. Advancing `help_scroll`
    // should slide the content up so later rows appear and earlier
    // rows leave the viewport.
    let mut app = App::new(AppOptions::default());
    let frame_top = render_to_string(80, 14, &app);
    assert!(
      frame_top.contains("General"),
      "General must be visible at scroll=0:\n{frame_top}"
    );
    // Show the scroll affordance in the title chip when overflow exists.
    assert!(
      frame_top.contains("j/k:scroll"),
      "scroll hint missing in close chip:\n{frame_top}"
    );
    app.help_scroll = 30;
    let frame_bottom = render_to_string(80, 14, &app);
    assert_ne!(
      frame_top, frame_bottom,
      "advancing help_scroll must change the rendered viewport"
    );
  }
}
