//! Shared session picker helpers.
//!
//! Centralises data types, entry building, and index-mapping logic used by
//! both the welcome-screen session picker (`welcome/mod.rs` + `app_view.rs`)
//! and the modal session picker (`ActiveModal::SessionPicker` in
//! `agent_view.rs`).

use std::collections::HashSet;

use indexmap::IndexMap;

use crate::app::app_view::SessionPickerEntry;
use crate::views::picker::{PickerEntry, PickerField, PickerRow, PickerState};

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Offset added to content-hit indices in the picker `expanded` set so
/// they don't collide with fuzzy-entry indices.
pub const CONTENT_EXPAND_OFFSET: usize = 100_000;

/// Derive a short repo display name from a CWD path.
///
/// Uses the last 2 normal path components joined by `-`. For paths with
/// only one normal component (e.g., `/xai`), returns that component alone.
/// Does not perform tilde expansion — callers provide absolute paths.
/// Returns `"unknown"` for empty input.
///
/// Examples: `/home/user/fw/1` → `"fw-1"`, `/xai` → `"xai"`, `/` → `"/"`.
///
/// Shared by the session-list builder (which stamps each entry's `repo_name`)
/// and the picker pinning below, so the current-cwd key matches a group key.
/// Callers pass the *live* cwd (`app.cwd` / `agent.session.cwd`) so a project
/// switch (`Effect::SetWorkingDir`) is reflected immediately.
pub(crate) fn repo_name_from_cwd(cwd: &str) -> String {
    let path = std::path::Path::new(cwd);
    let components: Vec<&str> = path
        .components()
        .filter_map(|c| match c {
            std::path::Component::Normal(os) => os.to_str(),
            _ => None,
        })
        .collect();
    if components.is_empty() {
        return if cwd.is_empty() {
            "unknown".to_string()
        } else {
            cwd.to_string()
        };
    }
    let start = components.len().saturating_sub(2);
    let tail = &components[start..];
    tail.join("-")
}

/// Order repo groups alphabetically, then pin the current working
/// directory's repo group (if present) to the front. Shared by
/// [`build_entry_map`] and [`build_grouped_picker_entries`] so the
/// index-mapping and rendering paths stay in lock-step.
fn order_repo_groups(groups: &mut IndexMap<&str, Vec<usize>>, current_repo: Option<&str>) {
    groups.sort_keys();
    if let Some(cur) = current_repo
        && let Some(pos) = groups.keys().position(|k| *k == cur)
    {
        groups.move_index(pos, 0);
    }
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// Which underlying data a picker position maps to.
#[derive(Debug, Clone)]
pub enum PickerItem {
    Fuzzy { original_index: usize },
    Content { hit_index: usize },
}

/// Owned data for a single session picker row. Built once per frame and
/// then borrowed by `PickerEntry` / `PickerField` slices. Shared between
/// the welcome-screen `render_session_picker` and the
/// `ActiveModal::SessionPicker` rendering in `agent_view.rs`.
pub struct SessionEntryData {
    pub summary: String,
    pub right_text: String,
    pub is_selected: bool,
    pub is_expanded: bool,
    pub field_data: Vec<(String, String)>,
    /// Short snippet preview for content search hits (always visible).
    pub snippet_preview: Option<String>,
    pub badge: &'static str,
    pub collapsible: bool,
}

#[derive(Debug, Clone)]
pub(crate) enum SessionPickerPendingNotice {
    Empty(String),
    Error(String),
}

/// Native/foreign completion state shared by session picker surfaces.
#[derive(Debug, Clone, Default)]
pub struct SessionPickerLanes {
    pub(crate) foreign_loading: bool,
    pub(crate) pending_notice: Option<SessionPickerPendingNotice>,
}

impl SessionPickerLanes {
    pub(crate) fn take_ready_notice(&mut self, has_entries: bool) -> Option<String> {
        match self.pending_notice.take() {
            Some(SessionPickerPendingNotice::Empty(message)) if !has_entries => Some(message),
            Some(SessionPickerPendingNotice::Error(message)) => Some(message),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Source filter
// ---------------------------------------------------------------------------

/// Filter session entries by native, remote, or external source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceFilter {
    #[default]
    All,
    Local,
    Remote,
    External,
}

impl SourceFilter {
    pub fn label(self) -> &'static str {
        match self {
            Self::All => "All",
            Self::Local => "Local",
            Self::Remote => "Remote",
            Self::External => "External",
        }
    }

    pub fn next(self) -> Self {
        match self {
            Self::All => Self::Local,
            Self::Local => Self::Remote,
            Self::Remote => Self::External,
            Self::External => Self::All,
        }
    }

    /// Returns `true` when a non-default filter is selected.
    pub fn is_active(self) -> bool {
        self != Self::All
    }

    /// Returns `true` if a session with the given `source` string passes the filter.
    ///
    /// kimi.com conversations carry `source == "conversation"` and live remotely,
    /// so they pass the `Remote` filter (and `All`) but not `Local`.
    pub fn matches(self, source: &str) -> bool {
        match self {
            Self::All => true,
            Self::Local => source == "local" || source == "both",
            Self::Remote => source == "remote" || source == "both" || source == "conversation",
            Self::External => crate::app::is_foreign_picker_source(source),
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct PickerSelectionAnchor {
    key: Option<PickerSelectionKey>,
    fallback_index: usize,
    scroll_delta: Option<isize>,
}

#[derive(Debug, Clone)]
enum PickerSelectionKey {
    Fuzzy { source: String, id: String },
    Content { id: String },
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn capture_picker_selection(
    entries: Option<&[SessionPickerEntry]>,
    content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
    state: &PickerState,
    query: &str,
    grouped: bool,
    content_loading: bool,
    source_filter: SourceFilter,
    current_repo: Option<&str>,
) -> PickerSelectionAnchor {
    let map = build_entry_map(
        entries,
        content_results,
        query,
        grouped,
        content_loading,
        source_filter,
        current_repo,
    );
    let key = map
        .get(state.selected)
        .and_then(|item| item.as_ref())
        .and_then(|item| match item {
            PickerItem::Fuzzy { original_index } => entries
                .and_then(|entries| entries.get(*original_index))
                .map(|entry| PickerSelectionKey::Fuzzy {
                    source: entry.source.clone(),
                    id: entry.id.clone(),
                }),
            PickerItem::Content { hit_index } => content_results
                .and_then(|results| results.get(*hit_index))
                .map(|hit| PickerSelectionKey::Content {
                    id: hit.session_id.clone(),
                }),
        });
    PickerSelectionAnchor {
        key,
        fallback_index: state.selected,
        scroll_delta: state
            .scroll_offset
            .map(|offset| state.selected as isize - offset as isize),
    }
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn restore_picker_selection(
    anchor: PickerSelectionAnchor,
    entries: Option<&[SessionPickerEntry]>,
    content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
    state: &mut PickerState,
    query: &str,
    grouped: bool,
    content_loading: bool,
    source_filter: SourceFilter,
    current_repo: Option<&str>,
) {
    let map = build_entry_map(
        entries,
        content_results,
        query,
        grouped,
        content_loading,
        source_filter,
        current_repo,
    );
    let selected = anchor
        .key
        .as_ref()
        .and_then(|key| {
            map.iter().position(|item| match (key, item.as_ref()) {
                (
                    PickerSelectionKey::Fuzzy { source, id },
                    Some(PickerItem::Fuzzy { original_index }),
                ) => entries
                    .and_then(|entries| entries.get(*original_index))
                    .is_some_and(|entry| &entry.source == source && &entry.id == id),
                (PickerSelectionKey::Content { id }, Some(PickerItem::Content { hit_index })) => {
                    content_results
                        .and_then(|results| results.get(*hit_index))
                        .is_some_and(|hit| &hit.session_id == id)
                }
                _ => false,
            })
        })
        .or_else(|| selectable_fallback(&map, anchor.fallback_index))
        .unwrap_or(0);
    state.selected = selected;
    state.scroll_offset = anchor.scroll_delta.map(|delta| {
        (selected as isize - delta)
            .max(0)
            .min(map.len().saturating_sub(1) as isize) as usize
    });
}

fn selectable_fallback<T>(map: &[Option<T>], preferred: usize) -> Option<usize> {
    if map.is_empty() {
        return None;
    }
    let preferred = preferred.min(map.len() - 1);
    (preferred..map.len())
        .find(|index| map[*index].is_some())
        .or_else(|| (0..preferred).rev().find(|index| map[*index].is_some()))
}

// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------

/// Case-insensitive substring match (callers pass a pre-lowercased query).
///
/// Deliberately not an ordered-chars subsequence match: that matched so
/// loosely (e.g. "rc" hitting "rust-check") that spurious title rows
/// drowned out the results users actually searched for.
pub(crate) fn fuzzy_matches_session(name: &str, query: &str) -> bool {
    query.is_empty() || name.to_lowercase().contains(query)
}

/// The query the picker's local fuzzy filter should apply on top of the
/// current entries.
///
/// When `entries_query` (the query the entries were server-fetched with)
/// matches the live query, the entries are already filtered server-side — by
/// message content as well as title — so the local fuzzy match is skipped:
/// re-applying it would hide content-only hits. Every consumer of
/// [`filter_session_entries`] / [`build_entry_map`] on picker state must use
/// this so input handling, rendering, and cursor re-anchoring agree on row
/// indices.
pub(crate) fn effective_filter_query<'a>(
    live_query: &'a str,
    entries_query: Option<&str>,
) -> &'a str {
    if entries_query.is_some_and(|q| q.trim() == live_query.trim()) {
        ""
    } else {
        live_query
    }
}

/// Filter session entries by query and source filter, returning indices of matching entries.
///
/// When the query is empty, all entries match the text filter. The
/// `source_filter` is always applied (entries whose `source` field
/// does not pass [`SourceFilter::matches`] are excluded).
pub(crate) fn filter_session_entries(
    entries: Option<&[SessionPickerEntry]>,
    query: &str,
    source_filter: SourceFilter,
) -> Vec<usize> {
    let Some(entries) = entries else {
        return vec![];
    };
    let q = query.to_lowercase();
    entries
        .iter()
        .enumerate()
        .filter(|(_, e)| {
            source_filter.matches(&e.source)
                && (query.is_empty()
                    || fuzzy_matches_session(&e.id, &q)
                    || fuzzy_matches_session(&e.summary, &q))
        })
        .map(|(i, _)| i)
        .collect()
}

// ---------------------------------------------------------------------------
// Entry map building
// ---------------------------------------------------------------------------

/// Build a flat list of picker items from fuzzy + content results,
/// deduplicating content hits that already appear in the fuzzy list.
pub(crate) fn build_virtual_list(
    entries: Option<&[SessionPickerEntry]>,
    content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
    query: &str,
    source_filter: SourceFilter,
) -> Vec<PickerItem> {
    let fuzzy_indices = filter_session_entries(entries, query, source_filter);
    let fuzzy_ids: HashSet<&str> = entries
        .map(|ents| {
            fuzzy_indices
                .iter()
                .filter_map(|&i| {
                    ents.get(i)
                        .filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
                        .map(|entry| entry.id.as_str())
                })
                .collect()
        })
        .unwrap_or_default();
    let mut items: Vec<PickerItem> = fuzzy_indices
        .into_iter()
        .map(|i| PickerItem::Fuzzy { original_index: i })
        .collect();
    if source_filter != SourceFilter::External
        && let Some(hits) = content_results
    {
        for (hit_idx, hit) in hits.iter().enumerate() {
            if !fuzzy_ids.contains(hit.session_id.as_str()) {
                items.push(PickerItem::Content { hit_index: hit_idx });
            }
        }
    }
    items
}

/// Build a position-indexed entry map for the session picker.
///
/// Each element is `Some(item)` for selectable rows or `None` for
/// non-selectable headers. When `grouped` is true, repo-group headers
/// are interleaved so indices match what the renderer stores in hit areas.
/// `current_repo` pins the matching repo group to the top of the list.
pub(crate) fn build_entry_map(
    entries: Option<&[SessionPickerEntry]>,
    content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
    query: &str,
    grouped: bool,
    content_loading: bool,
    source_filter: SourceFilter,
    current_repo: Option<&str>,
) -> Vec<Option<PickerItem>> {
    if grouped {
        let entries_data = entries.unwrap_or(&[]);
        let filtered = filter_session_entries(entries, query, source_filter);
        let mut map: Vec<Option<PickerItem>> = Vec::new();
        {
            let mut groups: IndexMap<&str, Vec<usize>> = IndexMap::new();
            for &orig_idx in &filtered {
                groups
                    .entry(entries_data[orig_idx].repo_name.as_str())
                    .or_default()
                    .push(orig_idx);
            }
            order_repo_groups(&mut groups, current_repo);
            for (_repo, members) in &groups {
                map.push(None); // repo group header
                for &orig_idx in members {
                    map.push(Some(PickerItem::Fuzzy {
                        original_index: orig_idx,
                    }));
                }
            }
        }
        // Append deduplicated content results (only when searching).
        let fuzzy_ids: HashSet<&str> = filtered
            .iter()
            .filter_map(|&i| {
                entries_data
                    .get(i)
                    .filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
                    .map(|entry| entry.id.as_str())
            })
            .collect();
        let content_items: Vec<usize> = if source_filter != SourceFilter::External
            && let Some(hits) = content_results
            && !query.is_empty()
        {
            hits.iter()
                .enumerate()
                .filter(|(_, h)| !fuzzy_ids.contains(h.session_id.as_str()))
                .map(|(idx, _)| idx)
                .collect()
        } else {
            Vec::new()
        };
        let show_content_header = !content_items.is_empty()
            || (source_filter != SourceFilter::External
                && content_loading
                && !query.trim().is_empty());
        if show_content_header {
            map.push(None); // content header
        }
        for hit_idx in content_items {
            map.push(Some(PickerItem::Content { hit_index: hit_idx }));
        }
        map
    } else {
        let content_for_flat = if query.is_empty() || source_filter == SourceFilter::External {
            None
        } else {
            content_results
        };
        let virtual_list = build_virtual_list(entries, content_for_flat, query, source_filter);
        let fuzzy_count = virtual_list
            .iter()
            .filter(|i| matches!(i, PickerItem::Fuzzy { .. }))
            .count();
        let content_count = virtual_list.len() - fuzzy_count;
        let has_header = content_count > 0
            || (source_filter != SourceFilter::External
                && content_loading
                && !query.trim().is_empty());
        let mut map = Vec::with_capacity(virtual_list.len() + usize::from(has_header));
        for (i, item) in virtual_list.into_iter().enumerate() {
            if has_header && i == fuzzy_count {
                map.push(None); // content header
            }
            map.push(Some(item));
        }
        if has_header && content_count == 0 {
            map.push(None); // loading header with no results yet
        }
        map
    }
}

// ---------------------------------------------------------------------------
// Session entry data building
// ---------------------------------------------------------------------------

/// Build owned rendering data for each session entry in the filtered list.
///
/// The caller zips the result with `PickerField` slices and builds
/// `PickerEntry` items that borrow from the returned data.
pub(crate) fn build_session_entry_data(
    entries_data: &[SessionPickerEntry],
    filtered_indices: &[usize],
    state: &PickerState,
    content_width: u16,
) -> Vec<SessionEntryData> {
    use crate::render::line_utils::truncate_str;

    filtered_indices
        .iter()
        .enumerate()
        .map(|(fi, &orig_idx)| {
            let entry = &entries_data[orig_idx];
            let summary = if entry.summary.is_empty() {
                "(no prompt)".to_string()
            } else {
                entry.summary.clone()
            };
            // Prefer last_active_at; fall back to updated_at (not created_at)
            // so pre-migration sessions don't jump to their creation date.
            let right_text = format_time_ago(entry.last_active_at.unwrap_or(entry.updated_at));
            let is_selected = !state.selection_hidden && fi == state.selected;
            let is_foreign = crate::app::is_foreign_picker_source(&entry.source);
            let is_expanded = !is_foreign && state.expanded.contains(&orig_idx);

            let mut field_data: Vec<(String, String)> = Vec::new();
            if is_expanded {
                field_data.push(("ID".into(), entry.id.clone()));
                field_data.push(("CWD".into(), entry.cwd.clone()));
                if let Some(ref model) = entry.model_id {
                    field_data.push(("Model".into(), model.clone()));
                }
                let fmt_time = |dt: chrono::DateTime<chrono::Utc>| {
                    dt.with_timezone(&chrono::Local)
                        .format("%b %d, %l:%M%P")
                        .to_string()
                };
                field_data.push(("Created".into(), fmt_time(entry.created_at)));
                field_data.push(("Updated".into(), fmt_time(entry.updated_at)));
                field_data.push(("Source".into(), entry.source.clone()));
                if let Some(ref host) = entry.hostname {
                    field_data.push(("Host".into(), host.clone()));
                }
                if entry.num_messages > 0 {
                    field_data.push(("Messages".into(), entry.num_messages.to_string()));
                }
                if let Some(ref detail) = entry.card_detail {
                    field_data.push((
                        "Turns".into(),
                        format!("{}    Tools  {}", detail.turn_count, detail.tool_call_count),
                    ));
                    if !detail.first_prompt_preview.is_empty() {
                        let max_w = content_width.saturating_sub(4 + 12) as usize;
                        let preview = truncate_str(&detail.first_prompt_preview, max_w);
                        field_data.push(("Prompt".into(), preview));
                    }
                }
            }

            SessionEntryData {
                summary,
                right_text,
                is_selected,
                is_expanded,
                field_data,
                snippet_preview: None,
                badge: crate::app::badge_for_picker_source(&entry.source),
                collapsible: !is_foreign,
            }
        })
        .collect()
}

/// Build grouped picker entries: sessions grouped by `repo_name` (the
/// current working directory's repo pinned first, the rest alphabetical)
/// with non-selectable `Header` rows separating each group. Returns the
/// entry list and a boolean mask where `true` marks non-selectable header rows.
pub(crate) fn build_grouped_picker_entries<'a>(
    entries_data: &'a [SessionPickerEntry],
    filtered_indices: &[usize],
    built: &'a [SessionEntryData],
    fields_vecs: &'a [Vec<PickerField<'a>>],
    state: &PickerState,
    current_repo: Option<&str>,
) -> (Vec<PickerEntry<'a>>, Vec<bool>) {
    // Group filtered entries by repo_name, sort alphabetically, then pin the
    // current working directory's repo group to the top.
    let mut groups: IndexMap<&str, Vec<usize>> = IndexMap::new();
    for (fi, &orig_idx) in filtered_indices.iter().enumerate() {
        let repo = entries_data[orig_idx].repo_name.as_str();
        groups.entry(repo).or_default().push(fi);
    }
    order_repo_groups(&mut groups, current_repo);

    let mut result: Vec<PickerEntry<'a>> = Vec::new();
    let mut non_selectable: Vec<bool> = Vec::new();

    // Track the grouped position (including headers) to correctly compute selection.
    let mut grouped_pos: usize = 0;
    for (repo_name, member_indices) in &groups {
        // Insert a non-selectable header for this repo group.
        non_selectable.push(true);
        result.push(PickerEntry::Header { label: repo_name });
        grouped_pos += 1;

        // Insert each session row indented under the header.
        for &fi in member_indices {
            let b = &built[fi];
            let fields = &fields_vecs[fi];
            non_selectable.push(false);
            // Use grouped position for selection, not flat filtered index.
            let selected = !state.selection_hidden && grouped_pos == state.selected;
            result.push(PickerEntry::Row(PickerRow {
                label: &b.summary,
                right_label: &b.right_text,
                selected,
                expanded: b.is_expanded,
                fields,
                description_lines: &[],
                summary_lines: &[],
                dimmed: false,
                indent: 1,
                badge: b.badge,
                badge_color: None,
                collapsible: b.collapsible,
            }));
            grouped_pos += 1;
        }
    }

    (result, non_selectable)
}

// ---------------------------------------------------------------------------
// Content search helpers
// ---------------------------------------------------------------------------

/// Build owned rendering data for content search (deep search) result rows.
///
/// Deduplicates hits that already appear in the fuzzy results. The returned
/// entries should be appended after the fuzzy section (and its header row).
pub(crate) fn build_content_entry_data(
    hits: &[kigi_shell::extensions::session_search::SearchSessionHit],
    entries_data: &[SessionPickerEntry],
    filtered_indices: &[usize],
    state: &PickerState,
    content_start: usize,
) -> Vec<SessionEntryData> {
    let fuzzy_ids: HashSet<&str> = entries_data
        .iter()
        .enumerate()
        .filter(|(i, entry)| {
            filtered_indices.contains(i) && !crate::app::is_foreign_picker_source(&entry.source)
        })
        .map(|(_, e)| e.id.as_str())
        .collect();
    let mut row_offset = 0usize;
    hits.iter()
        .enumerate()
        .filter(|(_, h)| !fuzzy_ids.contains(h.session_id.as_str()))
        .map(|(hit_idx, h)| {
            let summary = if h.summary.is_empty() {
                h.snippet
                    .as_deref()
                    .unwrap_or("(no summary)")
                    .lines()
                    .next()
                    .unwrap_or_default()
                    .to_string()
            } else {
                h.summary.clone()
            };
            let right_text = if let Ok(dt) = h.updated_at.parse::<chrono::DateTime<chrono::Utc>>() {
                format_time_ago(dt)
            } else {
                String::new()
            };
            let is_selected =
                !state.selection_hidden && state.selected == content_start + row_offset;
            let is_expanded = state.expanded.contains(&(CONTENT_EXPAND_OFFSET + hit_idx));
            row_offset += 1;

            let mut field_data = Vec::new();
            if is_expanded {
                field_data.push(("ID".into(), h.session_id.clone()));
                field_data.push(("CWD".into(), h.cwd.clone()));
            }

            let snippet_preview = h.snippet.as_deref().and_then(|s| {
                let line = s.lines().find(|l| !l.trim().is_empty())?;
                if line.chars().count() > 80 {
                    let truncated: String = line.chars().take(77).collect();
                    Some(format!("{truncated}..."))
                } else {
                    Some(line.to_string())
                }
            });

            SessionEntryData {
                summary,
                right_text,
                is_selected,
                is_expanded,
                field_data,
                snippet_preview,
                badge: "",
                collapsible: true,
            }
        })
        .collect()
}

/// Build the content header label (spinner or "Content matches").
///
/// Returns an empty string when there are no content rows and no loading
/// spinner — the caller should skip rendering the header in that case.
pub(crate) fn build_content_header_label(
    content_loading: bool,
    has_content_rows: bool,
    tick: u64,
) -> String {
    if content_loading {
        let spinner_frames = crate::glyphs::dot_spinner_frames();
        let frame_idx = (tick / 4) as usize % spinner_frames.len();
        format!(
            "{} Searching session content\u{2026}",
            spinner_frames[frame_idx]
        )
    } else if has_content_rows {
        "Extended search results (remote and local sessions)".to_string()
    } else {
        String::new()
    }
}

// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------

/// Format a timestamp as a human-readable relative time.
pub(crate) fn format_time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
    let now = chrono::Utc::now();
    let duration = now.signed_duration_since(dt);

    let raw = if duration.num_minutes() < 1 {
        "just now".to_string()
    } else if duration.num_minutes() < 60 {
        format!("{}m ago", duration.num_minutes())
    } else if duration.num_hours() < 24 {
        format!("{}h ago", duration.num_hours())
    } else if duration.num_days() < 30 {
        format!("{}d ago", duration.num_days())
    } else {
        format!("{}mo ago", duration.num_days() / 30)
    };
    // Right-align to fixed width so the column doesn't jump
    format!("{:>8}", raw)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn repo_name_from_cwd_two_components() {
        assert_eq!(repo_name_from_cwd("/home/user/fw/1"), "fw-1");
    }

    #[test]
    fn repo_name_from_cwd_standard_path() {
        assert_eq!(repo_name_from_cwd("/home/user/xai"), "user-xai");
    }

    #[test]
    fn repo_name_from_cwd_empty() {
        assert_eq!(repo_name_from_cwd(""), "unknown");
    }

    #[test]
    fn repo_name_from_cwd_deep_path() {
        assert_eq!(
            repo_name_from_cwd("/home/user/projects/rust/myapp"),
            "rust-myapp"
        );
    }

    #[test]
    fn repo_name_from_cwd_root() {
        assert_eq!(repo_name_from_cwd("/"), "/");
    }

    #[test]
    fn repo_name_from_cwd_single_dir() {
        assert_eq!(repo_name_from_cwd("/xai"), "xai");
    }

    /// Substring-only title matching: the old ordered-chars fallback let
    /// short queries match most titles, drowning real hits in junk rows.
    #[test]
    fn fuzzy_matches_session_is_substring_only() {
        assert!(fuzzy_matches_session("Rust-Check pipeline", "rust-check"));
        assert!(fuzzy_matches_session("Fix session picker", "picker"));
        assert!(fuzzy_matches_session("anything", ""));
        assert!(
            !fuzzy_matches_session("rust-check", "rc"),
            "ordered-chars subsequence must no longer match"
        );
    }

    /// Entries stamped as server search results for the live query skip the
    /// local title/id fuzzy match: the backend also matches message content,
    /// so a hit titled nothing like the query must survive filtering.
    #[test]
    fn effective_filter_query_skips_fuzzy_for_server_search_results() {
        let mut hit = make_entry("conv-content-1", "r");
        // Content-only match: title has nothing in common with "hit".
        hit.summary = "Quarterly roadmap notes".into();
        let entries = vec![hit];

        // Without the stamp (plain fetch / stale stamp): fuzzy filter hides it.
        assert!(
            filter_session_entries(
                Some(&entries),
                effective_filter_query("hit", None),
                SourceFilter::All,
            )
            .is_empty(),
            "unstamped entries keep the local fuzzy filter"
        );
        assert!(
            filter_session_entries(
                Some(&entries),
                effective_filter_query("hit", Some("older query")),
                SourceFilter::All,
            )
            .is_empty(),
            "a stale stamp (newer fetch in flight) keeps the local filter"
        );

        // Stamped with the live query: server already filtered — row visible.
        assert_eq!(
            filter_session_entries(
                Some(&entries),
                effective_filter_query("hit", Some("hit")),
                SourceFilter::All,
            ),
            vec![0],
            "server search results must render even with an unrelated title"
        );
        // Trim-insensitive: the picker trims before fetching.
        assert_eq!(effective_filter_query(" hit ", Some("hit")), "");
        // Unfiltered fetch stamp (None) + empty live query: no-op.
        assert_eq!(effective_filter_query("", None), "");
    }

    fn make_entry(id: &str, repo: &str) -> SessionPickerEntry {
        SessionPickerEntry {
            id: id.into(),
            summary: id.into(),
            updated_at: chrono::Utc::now(),
            created_at: chrono::Utc::now(),
            cwd: format!("/{repo}"),
            hostname: None,
            source: String::new(),
            model_id: None,
            num_messages: 0,
            last_active_at: None,
            branch: None,
            repo_name: repo.into(),
            worktree_label: None,
            card_detail: None,
        }
    }

    fn make_content_hit(
        session_id: &str,
    ) -> kigi_shell::extensions::session_search::SearchSessionHit {
        kigi_shell::extensions::session_search::SearchSessionHit {
            session_id: session_id.into(),
            summary: session_id.into(),
            cwd: "/r".into(),
            updated_at: chrono::Utc::now().to_rfc3339(),
            snippet: None,
            score: 1.0,
            matched_fields: vec![],
        }
    }

    /// Grouped entry map places repo headers and resolves mouse-click
    /// indices to the correct original session index.
    #[test]
    fn grouped_entry_map_resolves_correct_session() {
        let entries = vec![
            make_entry("s0", "repo-b"),
            make_entry("s1", "repo-a"),
            make_entry("s2", "repo-a"),
        ];

        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            false,
            SourceFilter::All,
            None,
        );

        // Expected layout (sorted by repo_name):
        //   0: None          (header "repo-a")
        //   1: Fuzzy(orig=1)  (s1 under repo-a)
        //   2: Fuzzy(orig=2)  (s2 under repo-a)
        //   3: None          (header "repo-b")
        //   4: Fuzzy(orig=0)  (s0 under repo-b)
        assert_eq!(map.len(), 5);
        assert!(map[0].is_none(), "repo-a header");
        assert!(
            matches!(map[1], Some(PickerItem::Fuzzy { original_index: 1 })),
            "first session under repo-a"
        );
        assert!(
            matches!(map[2], Some(PickerItem::Fuzzy { original_index: 2 })),
            "second session under repo-a"
        );
        assert!(map[3].is_none(), "repo-b header");
        assert!(
            matches!(map[4], Some(PickerItem::Fuzzy { original_index: 0 })),
            "session under repo-b"
        );

        // Mouse-click index 1 (first data row) must resolve to s1, not s0.
        match &map[1] {
            Some(PickerItem::Fuzzy { original_index }) => {
                assert_eq!(*original_index, 1);
                assert_eq!(entries[*original_index].id, "s1");
            }
            other => panic!("expected Fuzzy, got {other:?}"),
        }

        // Mouse-click index 4 (under repo-b header) resolves to s0.
        match &map[4] {
            Some(PickerItem::Fuzzy { original_index }) => {
                assert_eq!(*original_index, 0);
                assert_eq!(entries[*original_index].id, "s0");
            }
            other => panic!("expected Fuzzy, got {other:?}"),
        }
    }

    /// Grouped entry map with content search results: deduplicates content
    /// hits that overlap fuzzy results, places content header after all
    /// repo groups, and resolves content indices correctly.
    #[test]
    fn grouped_entry_map_with_content_results() {
        let entries = vec![make_entry("s0", "repo-a"), make_entry("s1", "repo-b")];
        // Two content hits: s0 overlaps fuzzy (should be deduped), s_new is unique.
        let content_hits = vec![make_content_hit("s0"), make_content_hit("s_new")];

        // query must be non-empty for content results to be included.
        // Use a query that matches all entries so fuzzy list is preserved.
        let map = build_entry_map(
            Some(&entries),
            Some(&content_hits),
            "s",
            true,
            false,
            SourceFilter::All,
            None,
        );

        // Expected:
        //   0: None            (header "repo-a")
        //   1: Fuzzy(orig=0)   (s0 under repo-a)
        //   2: None            (header "repo-b")
        //   3: Fuzzy(orig=1)   (s1 under repo-b)
        //   4: None            (content header)
        //   5: Content(idx=1)  (s_new — s0 deduped)
        assert_eq!(map.len(), 6);
        assert!(map[0].is_none(), "repo-a header");
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
        assert!(map[2].is_none(), "repo-b header");
        assert!(matches!(
            map[3],
            Some(PickerItem::Fuzzy { original_index: 1 })
        ));
        assert!(map[4].is_none(), "content header");
        assert!(
            matches!(map[5], Some(PickerItem::Content { hit_index: 1 })),
            "s_new at content hit index 1 (s0 deduped)"
        );
    }

    /// An in-flight search with an EMPTY effective query (stamp==live)
    /// appends no "Searching…" header row — a header only one of input map /
    /// render has would shift row indices.
    #[test]
    fn grouped_entry_map_empty_query_with_loading_has_no_header() {
        let entries = vec![make_entry("s0", "r")];
        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            /* content_loading */ true,
            SourceFilter::All,
            None,
        );
        assert_eq!(map.len(), 2, "repo header + row only, no content header");
        assert!(map[0].is_none(), "repo header");
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
    }

    /// Empty query in grouped mode must not include content results,
    /// matching the renderer's guard.
    #[test]
    fn grouped_entry_map_empty_query_excludes_content() {
        let entries = vec![make_entry("s0", "r")];
        let content_hits = vec![make_content_hit("s_new")];

        let map = build_entry_map(
            Some(&entries),
            Some(&content_hits),
            "",
            true,
            false,
            SourceFilter::All,
            None,
        );

        // Only the repo header + fuzzy entry; no content header/items.
        assert_eq!(map.len(), 2);
        assert!(map[0].is_none());
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
    }

    /// Flat entry map inserts content header at the correct position
    /// and resolution matches the old `to_virtual` semantics.
    #[test]
    fn flat_entry_map_matches_to_virtual_semantics() {
        let entries = vec![make_entry("s0", "r"), make_entry("s1", "r")];
        let content_hits = vec![make_content_hit("s_content")];

        // Empty query: content results excluded (matches renderer guard).
        let map = build_entry_map(
            Some(&entries),
            Some(&content_hits),
            "",
            false,
            false,
            SourceFilter::All,
            None,
        );
        assert_eq!(map.len(), 2);
        assert!(matches!(
            map[0],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 1 })
        ));

        // Non-empty query: content results included with header.
        let map = build_entry_map(
            Some(&entries),
            Some(&content_hits),
            "s",
            false,
            false,
            SourceFilter::All,
            None,
        );
        assert_eq!(map.len(), 4);
        assert!(matches!(
            map[0],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 1 })
        ));
        assert!(map[2].is_none(), "content header");
        assert!(matches!(map[3], Some(PickerItem::Content { hit_index: 0 })));
    }

    #[test]
    fn foreign_id_does_not_suppress_native_content_result() {
        let mut foreign = make_entry("shared", "repo");
        foreign.source = "codex".into();
        let entries = vec![foreign];
        let hits = vec![make_content_hit("shared")];

        let flat = build_entry_map(
            Some(&entries),
            Some(&hits),
            "shared",
            false,
            false,
            SourceFilter::All,
            None,
        );
        assert!(matches!(
            flat.as_slice(),
            [
                Some(PickerItem::Fuzzy { original_index: 0 }),
                None,
                Some(PickerItem::Content { hit_index: 0 }),
            ]
        ));

        let grouped = build_entry_map(
            Some(&entries),
            Some(&hits),
            "shared",
            true,
            false,
            SourceFilter::All,
            None,
        );
        assert!(matches!(
            grouped.as_slice(),
            [
                None,
                Some(PickerItem::Fuzzy { original_index: 0 }),
                None,
                Some(PickerItem::Content { hit_index: 0 }),
            ]
        ));

        let state = PickerState::default();
        let content = build_content_entry_data(&hits, &entries, &[0], &state, 2);
        assert_eq!(content.len(), 1);
        assert_eq!(content[0].summary, "shared");
    }

    #[test]
    fn content_header_label_loading() {
        let label = build_content_header_label(true, false, 0);
        assert!(label.contains("Searching"));
    }

    #[test]
    fn content_header_label_has_rows() {
        let label = build_content_header_label(false, true, 0);
        assert_eq!(label, "Extended search results (remote and local sessions)");
    }

    #[test]
    fn content_header_label_empty() {
        let label = build_content_header_label(false, false, 0);
        assert!(label.is_empty());
    }

    /// Empty entries list produces empty map.
    #[test]
    fn empty_entries_produces_empty_map() {
        let map = build_entry_map(None, None, "", true, false, SourceFilter::All, None);
        assert!(map.is_empty());

        let map = build_entry_map(Some(&[]), None, "", false, false, SourceFilter::All, None);
        assert!(map.is_empty());
    }

    #[test]
    fn source_filter_matches() {
        assert!(SourceFilter::All.matches("local"));
        assert!(SourceFilter::All.matches("remote"));
        assert!(SourceFilter::All.matches("both"));
        assert!(SourceFilter::All.matches("claude"));
        assert!(SourceFilter::All.matches("codex"));
        assert!(SourceFilter::All.matches("cursor"));

        assert!(SourceFilter::Local.matches("local"));
        assert!(SourceFilter::Local.matches("both"));
        assert!(!SourceFilter::Local.matches("remote"));
        assert!(!SourceFilter::Local.matches("claude"));

        assert!(SourceFilter::Remote.matches("remote"));
        assert!(SourceFilter::Remote.matches("both"));
        assert!(!SourceFilter::Remote.matches("local"));
        assert!(!SourceFilter::Remote.matches("cursor"));

        // kimi.com conversations are remote: visible under All + Remote, not Local.
        assert!(SourceFilter::All.matches("conversation"));
        assert!(SourceFilter::Remote.matches("conversation"));
        assert!(!SourceFilter::Local.matches("conversation"));

        assert!(SourceFilter::External.matches("claude"));
        assert!(SourceFilter::External.matches("codex"));
        assert!(SourceFilter::External.matches("cursor"));
        assert!(!SourceFilter::External.matches("local"));
        assert!(!SourceFilter::External.matches("remote"));
        assert!(!SourceFilter::External.matches("both"));
        assert!(!SourceFilter::External.matches("conversation"));
    }

    #[test]
    fn source_filter_cycles() {
        assert_eq!(SourceFilter::All.next(), SourceFilter::Local);
        assert_eq!(SourceFilter::Local.next(), SourceFilter::Remote);
        assert_eq!(SourceFilter::Remote.next(), SourceFilter::External);
        assert_eq!(SourceFilter::External.next(), SourceFilter::All);
        assert_eq!(SourceFilter::External.label(), "External");
    }

    #[test]
    fn source_filter_filters_entries() {
        fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry {
            let mut e = make_entry(id, "r");
            e.source = source.into();
            e
        }
        let entries = vec![
            entry_with_source("s0", "local"),
            entry_with_source("s1", "remote"),
            entry_with_source("s2", "both"),
            entry_with_source("s3", "claude"),
            entry_with_source("s4", "codex"),
            entry_with_source("s5", "cursor"),
        ];

        let all = filter_session_entries(Some(&entries), "", SourceFilter::All);
        assert_eq!(all, vec![0, 1, 2, 3, 4, 5]);

        let local = filter_session_entries(Some(&entries), "", SourceFilter::Local);
        assert_eq!(local, vec![0, 2]); // local + both

        let remote = filter_session_entries(Some(&entries), "", SourceFilter::Remote);
        assert_eq!(remote, vec![1, 2]); // remote + both

        let external = filter_session_entries(Some(&entries), "", SourceFilter::External);
        assert_eq!(external, vec![3, 4, 5]);
    }

    #[test]
    fn source_filter_empty_and_unknown_source() {
        // Empty source string (e.g. from old data or test fixtures) should
        // only pass the All filter, never Local or Remote.
        assert!(SourceFilter::All.matches(""));
        assert!(!SourceFilter::Local.matches(""));
        assert!(!SourceFilter::Remote.matches(""));

        // Unknown source values are also rejected by Local/Remote.
        assert!(SourceFilter::All.matches("unknown"));
        assert!(!SourceFilter::Local.matches("unknown"));
        assert!(!SourceFilter::Remote.matches("unknown"));
    }

    #[test]
    fn source_filter_is_active() {
        assert!(!SourceFilter::All.is_active());
        assert!(SourceFilter::Local.is_active());
        assert!(SourceFilter::Remote.is_active());
        assert!(SourceFilter::External.is_active());
    }

    #[test]
    fn foreign_entry_uses_source_badge_and_has_no_detail_expansion() {
        let mut entry = make_entry("foreign", "repo");
        entry.source = "codex".into();
        let mut state = PickerState::default();
        state.expanded.insert(0);

        let built = build_session_entry_data(&[entry], &[0], &state, 80);

        assert_eq!(built[0].badge, "codex");
        assert!(!built[0].collapsible);
        assert!(!built[0].is_expanded);
        assert!(built[0].field_data.is_empty());
    }

    #[test]
    fn source_filter_combined_with_text_query() {
        fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry {
            let mut e = make_entry(id, "r");
            e.source = source.into();
            e
        }
        let entries = vec![
            entry_with_source("alpha", "local"),
            entry_with_source("beta", "remote"),
            entry_with_source("gamma", "both"),
        ];

        // Text query "alpha" + Local filter: only alpha matches both criteria.
        let result = filter_session_entries(Some(&entries), "alpha", SourceFilter::Local);
        assert_eq!(result, vec![0]);

        // Text query "alpha" + Remote filter: alpha is local-only, so no matches.
        let result = filter_session_entries(Some(&entries), "alpha", SourceFilter::Remote);
        assert!(result.is_empty());

        // Text query matching all + Local filter: local + both pass.
        let result = filter_session_entries(Some(&entries), "", SourceFilter::Local);
        assert_eq!(result, vec![0, 2]);
    }

    #[test]
    fn grouped_entry_map_with_source_filter() {
        fn entry_with_source(id: &str, repo: &str, source: &str) -> SessionPickerEntry {
            let mut e = make_entry(id, repo);
            e.source = source.into();
            e
        }
        let entries = vec![
            entry_with_source("s0", "repo-a", "local"),
            entry_with_source("s1", "repo-a", "remote"),
            entry_with_source("s2", "repo-b", "both"),
        ];

        // Local filter: s0 (local) + s2 (both), grouped by repo.
        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            false,
            SourceFilter::Local,
            None,
        );
        // repo-a header + s0 + repo-b header + s2 = 4
        assert_eq!(map.len(), 4);
        assert!(map[0].is_none()); // repo-a header
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
        assert!(map[2].is_none()); // repo-b header
        assert!(matches!(
            map[3],
            Some(PickerItem::Fuzzy { original_index: 2 })
        ));

        // Remote filter: s1 (remote) + s2 (both).
        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            false,
            SourceFilter::Remote,
            None,
        );
        assert_eq!(map.len(), 4);
        assert!(map[0].is_none()); // repo-a header
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 1 })
        ));
        assert!(map[2].is_none()); // repo-b header
        assert!(matches!(
            map[3],
            Some(PickerItem::Fuzzy { original_index: 2 })
        ));
    }

    /// `current_repo` pins the matching group to the top; remaining groups
    /// stay alphabetical. Without it, groups are purely alphabetical.
    #[test]
    fn grouped_entry_map_pins_current_repo_first() {
        let entries = vec![
            make_entry("s0", "repo-a"),
            make_entry("s1", "repo-b"),
            make_entry("s2", "repo-c"),
        ];

        // Pin repo-c: its group leads, then repo-a, repo-b alphabetically.
        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            false,
            SourceFilter::All,
            Some("repo-c"),
        );
        // [repo-c hdr, s2, repo-a hdr, s0, repo-b hdr, s1]
        assert_eq!(map.len(), 6);
        assert!(map[0].is_none(), "repo-c header pinned first");
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 2 })
        ));
        assert!(map[2].is_none(), "repo-a header");
        assert!(matches!(
            map[3],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
        assert!(map[4].is_none(), "repo-b header");
        assert!(matches!(
            map[5],
            Some(PickerItem::Fuzzy { original_index: 1 })
        ));

        // A current_repo with no matching group is a no-op (pure alphabetical).
        let map = build_entry_map(
            Some(&entries),
            None,
            "",
            true,
            false,
            SourceFilter::All,
            Some("repo-zzz"),
        );
        assert!(map[0].is_none(), "repo-a header");
        assert!(matches!(
            map[1],
            Some(PickerItem::Fuzzy { original_index: 0 })
        ));
    }
}
