//! Snapshot picker (F6), snapshot annotation (F5), snapshot
//! diff (V from picker), and split-snapshot accept/restore —
//! every App method that mutates the snapshot store or its
//! picker modal. Extracted from `tui::app` in the 1.2.7
//! refactor, Phase 3 batch 4.

use ratatui::style::{Color, Modifier, Style};
use tui_textarea::TextArea;
use uuid::Uuid;

use super::super::diff_utils::compute_line_diff;
use super::super::focus::Focus;
use super::super::input::TextInput;
use super::super::modal::Modal;
use super::super::session::ParagraphCursor;
use super::super::text_utils::body_to_lines;

impl super::App {

    /// Copy the currently-open paragraph's cursor + scroll into the
    /// in-memory `paragraph_cursors` map. Called on focus loss, on
    /// paragraph switch, and right before `save_session` writes to disk.
    /// No-op when no paragraph is open.
    pub(super) fn snapshot_open_paragraph_cursor(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            return;
        };
        let (row, col) = doc.textarea.cursor();
        self.paragraph_cursors.insert(
            doc.id,
            ParagraphCursor {
                cursor_row: row,
                cursor_col: col,
                scroll_row: doc.scroll_row,
                scroll_col: doc.scroll_col,
            },
        );
    }

    /// Replace the live buffer with the split snapshot and exit split mode.
    /// Used to "roll back" to the captured version after experimenting.
    pub(super) fn accept_split_snapshot(&mut self) {
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        let Some(split) = doc.split.take() else {
            self.status = "split is not open".into();
            return;
        };
        let lines = if split.snapshot_lines.is_empty() {
            vec![String::new()]
        } else {
            split.snapshot_lines
        };
        let mut new_ta = TextArea::new(lines);
        new_ta.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED));
        new_ta.set_line_number_style(Style::default().fg(Color::DarkGray));
        doc.textarea = new_ta;
        doc.dirty = true;
        doc.scroll_row = 0;
        doc.scroll_col = 0;
        doc.last_activity = std::time::Instant::now();
        self.status =
            "split snapshot accepted — buffer replaced; Ctrl+S to commit · bold shows the diff"
                .into();
    }

    pub(super) fn create_snapshot_of_current(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n").into_bytes();
        let id = doc.id;
        let Some(node) = self.hierarchy.get(id).cloned() else {
            self.status = "node missing from hierarchy".into();
            return;
        };
        // 1.2.7+ — dedupe. If the latest existing snapshot for
        // this paragraph has identical content, skip the
        // annotation prompt entirely and stamp a "no changes"
        // status line. Stops F5 mashing from littering history
        // with duplicates AND keeps the annotation prompt
        // honest (no point annotating a no-op).
        if let Ok(snaps) = self.store.list_snapshots(id) {
            if let Some(latest) = snaps.first() {
                if let Ok(Some(prev)) =
                    self.store.snapshot_content(latest.id)
                {
                    if prev == body {
                        self.status = format!(
                            "snapshot: `{}` unchanged since the last snapshot — no new snapshot taken",
                            node.title
                        );
                        return;
                    }
                }
            }
        }
        // 1.2.6+ — pop an annotation prompt so the user can
        // jot a one-line note ("first complete draft", "before
        // the lighthouse rewrite"). Enter on empty input still
        // commits — keeps the F5 → Enter flow as fast as the
        // old one-keystroke path. Esc cancels.
        self.modal = Modal::SnapshotAnnotation {
            input: TextInput::new(),
            parent_id: id,
            parent_title: node.title,
            body,
        };
        self.status =
            "snapshot annotation: type a note (or Enter for no note) · Esc cancels".into();
    }

    /// Commit step for `Modal::SnapshotAnnotation` — invoked by
    /// the modal's Enter handler. Calls
    /// `Store::create_snapshot_annotated`, stamps the result on
    /// the status bar, and closes the modal.
    pub(super) fn commit_snapshot_annotation(
        &mut self,
        parent_id: Uuid,
        parent_title: &str,
        body: &[u8],
        annotation: &str,
    ) {
        let Some(node) = self.hierarchy.get(parent_id).cloned() else {
            self.status = "snapshot: paragraph vanished".into();
            return;
        };
        match self
            .store
            .create_snapshot_annotated(&node, body, annotation)
        {
            Ok(snap_id) => {
                let n_snaps = self
                    .store
                    .list_snapshots(parent_id)
                    .map(|v| v.len())
                    .unwrap_or(0);
                let note = if annotation.trim().is_empty() {
                    String::new()
                } else {
                    format!(" · `{annotation}`")
                };
                self.status = format!(
                    "snapshot {} of `{parent_title}` created ({} total){note} — F6 to view",
                    snap_id.simple(),
                    n_snaps,
                );
            }
            Err(e) => {
                self.status = format!("snapshot failed: {e}");
            }
        }
    }

    /// 1.2.11+ — snapshot the currently open
    /// paragraph with `annotation`, picking up the
    /// live editor body (not the on-disk file).
    /// Used by flows that need to label the
    /// pre-mutation state before they apply an
    /// AI rewrite — most notably the Ctrl+B Shift+M
    /// sentence-rhythm rewrite, which snapshots
    /// the unrewritten body annotated `Sentence
    /// rhythm rewrite` immediately before the
    /// rewrite lands.  No-op when no paragraph is
    /// open; failures land on the status bar.
    pub(super) fn snapshot_open_paragraph_with_annotation(
        &mut self,
        annotation: &str,
    ) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "snapshot: no paragraph open".into();
            return;
        };
        let parent_id = doc.id;
        let parent_title = doc.title.clone();
        let body = doc.textarea.lines().join("\n").into_bytes();
        self.commit_snapshot_annotation(
            parent_id,
            &parent_title,
            &body,
            annotation,
        );
    }

    pub(super) fn open_snapshot_picker(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "no paragraph open".into();
            return;
        };
        let id = doc.id;
        let title = doc.title.clone();
        match self.store.list_snapshots(id) {
            Ok(snapshots) => {
                if snapshots.is_empty() {
                    self.status =
                        format!("no snapshots yet for `{title}` — press F5 to create one");
                    return;
                }
                // 1.2.8+ — reset the annotation filter; previous
                // session's filter shouldn't haunt a fresh picker.
                self.snapshot_filter.clear();
                self.snapshot_filter_focused = false;
                self.modal = Modal::SnapshotPicker {
                    paragraph_id: id,
                    paragraph_title: title,
                    snapshots,
                    cursor: 0,
                };
            }
            Err(e) => {
                self.status = format!("snapshot list failed: {e}");
            }
        }
    }

    /// 1.3.36 — open the project-wide snapshot browser (Ctrl+F6).
    pub(super) fn open_snapshot_browser(&mut self) {
        match self.store.list_all_snapshots() {
            Ok(entries) => {
                if entries.is_empty() {
                    self.status =
                        "no snapshots in this project yet — press F5 on a paragraph".into();
                    return;
                }
                let n = entries.len();
                self.modal = Modal::SnapshotBrowser {
                    entries,
                    cursor: 0,
                    scroll: 0,
                    filter: String::new(),
                    filter_focused: false,
                };
                self.status = format!(
                    "snapshots: {n} across the project · ↑↓ move · / filter · V diff · Enter open · Esc close"
                );
            }
            Err(e) => {
                self.status = format!("snapshot browser failed: {e}");
            }
        }
    }

    /// Current text of an arbitrary paragraph by id: the live editor
    /// buffer when it's the open one (so unsaved edits show in the
    /// diff), otherwise the on-disk body. Empty string when the node
    /// is gone or has no file.
    fn paragraph_current_text(&self, parent_id: Uuid) -> String {
        if let Some(doc) = self.opened.as_ref() {
            if doc.id == parent_id {
                return doc.textarea.lines().join("\n");
            }
        }
        match self.hierarchy.get(parent_id).and_then(|n| n.file.clone()) {
            Some(rel) => std::fs::read_to_string(self.layout.root.join(rel)).unwrap_or_default(),
            None => String::new(),
        }
    }

    /// Diff the browser's selected snapshot against its paragraph's
    /// current text, reusing the `SnapshotDiff` modal. `Esc` returns
    /// to the browser.
    pub(super) fn open_snapshot_browser_diff(&mut self) {
        let (snap_id, parent_id, when, title) = match &self.modal {
            Modal::SnapshotBrowser {
                entries, cursor, filter, ..
            } => {
                let visible = browser_visible_indices(entries, filter);
                let Some(&abs) = visible.get(*cursor) else { return };
                let Some((t, s)) = entries.get(abs) else { return };
                (s.id, s.parent_id, s.created_at, t.clone())
            }
            _ => return,
        };
        let snapshot_text = match self.store.snapshot_content(snap_id) {
            Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
            Ok(None) => {
                self.status = "snapshot has no body".into();
                return;
            }
            Err(e) => {
                self.status = format!("snapshot load failed: {e}");
                return;
            }
        };
        let current_text = self.paragraph_current_text(parent_id);
        let rows = compute_line_diff(&snapshot_text, &current_text);
        let when_str = when
            .with_timezone(&chrono::Local)
            .format("%Y-%m-%d %H:%M:%S %z")
            .to_string();
        let return_to = Box::new(std::mem::replace(&mut self.modal, Modal::None));
        self.modal = Modal::SnapshotDiff {
            paragraph_title: title,
            when: when_str,
            rows,
            scroll: 0,
            return_to,
        };
        self.status = "diff: snapshot ← left · current → right · ↑↓ scroll · Esc back".into();
    }

    /// `Enter` in the browser: open the selected snapshot's paragraph
    /// in the editor and drop straight into its F6 picker, where the
    /// full per-paragraph history (load / restore / pin) lives.
    pub(super) fn snapshot_browser_jump(&mut self) {
        let parent_id = match &self.modal {
            Modal::SnapshotBrowser {
                entries, cursor, filter, ..
            } => {
                let visible = browser_visible_indices(entries, filter);
                let Some(&abs) = visible.get(*cursor) else { return };
                let Some((_, s)) = entries.get(abs) else { return };
                s.parent_id
            }
            _ => return,
        };
        self.modal = Modal::None;
        match self.open_paragraph_by_uuid(parent_id) {
            Ok(()) => self.open_snapshot_picker(),
            Err(e) => self.status = format!("snapshot browser: can't open paragraph — {e}"),
        }
    }

    pub(super) fn snapshot_browser_handle_key(&mut self, key: crossterm::event::KeyEvent) {
        use crossterm::event::KeyCode;
        // Filter-focused mode: type into the filter, Enter/Esc leave it.
        let filter_focused = matches!(
            &self.modal,
            Modal::SnapshotBrowser { filter_focused: true, .. }
        );
        if filter_focused {
            if let Modal::SnapshotBrowser { filter, filter_focused, cursor, scroll, .. } =
                &mut self.modal
            {
                match key.code {
                    // Esc-while-filtering is intercepted upstream (it
                    // unfocuses); Enter confirms the filter here.
                    KeyCode::Enter => *filter_focused = false,
                    KeyCode::Backspace => {
                        filter.pop();
                        *cursor = 0;
                        *scroll = 0;
                    }
                    KeyCode::Char(c) => {
                        filter.push(c);
                        *cursor = 0;
                        *scroll = 0;
                    }
                    _ => {}
                }
            }
            return;
        }
        match key.code {
            KeyCode::Char('/') => {
                if let Modal::SnapshotBrowser { filter_focused, .. } = &mut self.modal {
                    *filter_focused = true;
                }
            }
            KeyCode::Char('V') | KeyCode::Char('v') => self.open_snapshot_browser_diff(),
            KeyCode::Enter => self.snapshot_browser_jump(),
            KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End => {
                if let Modal::SnapshotBrowser { entries, cursor, filter, .. } = &mut self.modal {
                    let n = browser_visible_indices(entries, filter).len();
                    if n == 0 {
                        return;
                    }
                    match key.code {
                        KeyCode::Up => *cursor = cursor.saturating_sub(1),
                        KeyCode::Down => *cursor = (*cursor + 1).min(n - 1),
                        KeyCode::Home => *cursor = 0,
                        KeyCode::End => *cursor = n - 1,
                        _ => {}
                    }
                }
            }
            _ => {}
        }
    }

    /// 1.2.8+ — return indices into `snaps` that match the
    /// current annotation filter (case-insensitive substring
    /// against annotation text). Empty filter returns every
    /// index in order. The picker uses these via
    /// `visible[cursor]` to look up the absolute snapshot the
    /// cursor refers to.
    pub(super) fn visible_snapshot_indices(
        &self,
        snaps: &[crate::store::Snapshot],
    ) -> Vec<usize> {
        if self.snapshot_filter.is_empty() {
            return (0..snaps.len()).collect();
        }
        let needle = self.snapshot_filter.to_lowercase();
        snaps
            .iter()
            .enumerate()
            .filter(|(_, s)| s.annotation.to_lowercase().contains(&needle))
            .map(|(i, _)| i)
            .collect()
    }

    /// 1.2.12+ — Shift+Enter on a snapshot picker
    /// row: load the snapshot's body into the
    /// split-view `secondary` slot as a *read-only*
    /// historical view of the paragraph.  Unlocks
    /// the draft-vs-current comparison workflow:
    /// snapshot in the right pane, live buffer in
    /// the left, `Shift+F4` to view side-by-side,
    /// `F12` to fire `critique-compare`.
    ///
    /// The pinned secondary's `read_only` flag is
    /// `true` — the user can scroll and select but
    /// can't edit.  The title is prefixed with the
    /// snapshot's timestamp so the historical-vs-
    /// live distinction is visible at a glance in
    /// the split-view title bar.
    ///
    /// Closes the picker modal on success.  On
    /// error, leaves the modal up so the user can
    /// retry or pick a different row.
    pub(super) fn pin_snapshot_to_secondary(&mut self) {
        let (snap_id, when) = match &self.modal {
            Modal::SnapshotPicker {
                snapshots, cursor, ..
            } => {
                let visible = self.visible_snapshot_indices(snapshots);
                let Some(abs_idx) = visible.get(*cursor) else {
                    return;
                };
                let Some(snap) = snapshots.get(*abs_idx) else {
                    return;
                };
                (snap.id, snap.created_at)
            }
            _ => return,
        };
        let content = match self.store.snapshot_content(snap_id) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => {
                self.status = "snapshot has no body — nothing to pin".into();
                return;
            }
            Err(e) => {
                self.status = format!("snapshot pin failed: {e}");
                return;
            }
        };
        let Some(primary) = self.opened.as_ref() else {
            self.status =
                "snapshot pin: no paragraph open to anchor against".into();
            return;
        };
        let primary_id = primary.id;
        let primary_title = primary.title.clone();
        let primary_rel = primary.rel_path.clone();
        let primary_content_type = primary.content_type.clone();
        let body = String::from_utf8_lossy(&content).into_owned();
        let lines = body_to_lines(&body);
        let saved_lines = lines.clone();
        let mut textarea = TextArea::new(lines);
        textarea.set_cursor_line_style(
            Style::default().add_modifier(Modifier::REVERSED),
        );
        textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
        // Format the snapshot age compactly for the
        // title so the user sees "(snapshot · 2h
        // ago)" in the split-view chrome.
        let age = {
            let now = chrono::Utc::now();
            let delta = now.signed_duration_since(when);
            let secs = delta.num_seconds().max(0) as u64;
            super::super::text_utils::format_age_humantime(
                std::time::Duration::from_secs(secs),
            )
        };
        let snap_title = format!("{primary_title} (snapshot · {age})");
        self.secondary = Some(super::super::state::OpenedDoc {
            id: primary_id,
            title: snap_title,
            rel_path: primary_rel,
            textarea,
            dirty: false,
            scroll_row: 0,
            scroll_col: 0,
            block_anchor: None,
            last_activity: std::time::Instant::now(),
            saved_lines,
            // 1.2.14+ Phase C.1 — snapshots are
            // read-only; comments don't render or
            // mutate from this slot.
            comments: super::super::comments::CommentsFile::new(),
            loaded_mtime: None,
            split: None,
            search: None,
            // Read-only — historical snapshot is for
            // comparison, not editing.
            read_only: true,
            correction_baseline: None,
            content_type: primary_content_type,
            typst_diagnostics: Vec::new(),
            typst_diagnostics_checked_at: std::time::Instant::now(),
            typst_diag_last_fired: None,
            detected_language: None,
            detected_language_length: 0,
        });
        self.secondary_focused = false;
        self.modal = Modal::None;
        self.status = format!(
            "snapshot pinned to secondary (read-only · {age}) — Shift+F4 to view split",
        );
    }

    pub(super) fn commit_snapshot_load(&mut self) {
        // 1.2.8+ — when the annotation filter is non-empty,
        // `cursor` indexes the FILTERED visible list, not the
        // absolute snapshots Vec.  Translate before reading.
        let (snap_id, when) = match &self.modal {
            Modal::SnapshotPicker {
                snapshots, cursor, ..
            } => {
                let visible = self.visible_snapshot_indices(snapshots);
                let Some(abs_idx) = visible.get(*cursor) else {
                    self.modal = Modal::None;
                    return;
                };
                let Some(snap) = snapshots.get(*abs_idx) else {
                    self.modal = Modal::None;
                    return;
                };
                (snap.id, snap.created_at)
            }
            _ => return,
        };
        let content = match self.store.snapshot_content(snap_id) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => {
                self.status = "snapshot has no body".into();
                self.modal = Modal::None;
                return;
            }
            Err(e) => {
                self.status = format!("snapshot load failed: {e}");
                self.modal = Modal::None;
                return;
            }
        };

        // Safety net (1.2.4+): before we replace the editor buffer,
        // snapshot whatever is currently in it. Without this, hitting
        // Enter on an old snapshot would silently discard any
        // unsaved typing — "oops! a day of work gone". Now the
        // recoverable history grows by one row instead.
        //
        // The pre-restore snapshot fires `hook.on_snapshot` like any
        // other snapshot. If snapshot creation itself fails, we
        // abort the load: the whole point is data safety, so doing
        // the replace without the safety net would defeat the
        // change. The user can fix the underlying error (disk full,
        // store offline) and retry.
        let pre_restore_id = if let Some(doc) = self.opened.as_ref() {
            let body_now = doc.textarea.lines().join("\n");
            let node = self.hierarchy.get(doc.id).cloned();
            match node {
                Some(n) => match self.store.create_snapshot(&n, body_now.as_bytes()) {
                    Ok(id) => Some(id),
                    Err(e) => {
                        self.status = format!(
                            "snapshot load aborted: safety snapshot failed ({e}) — retry once the store is healthy"
                        );
                        self.modal = Modal::None;
                        return;
                    }
                },
                None => None,
            }
        } else {
            None
        };

        let body = String::from_utf8_lossy(&content).into_owned();
        let Some(doc) = self.opened.as_mut() else {
            self.modal = Modal::None;
            return;
        };
        let mut new_textarea = TextArea::new(body_to_lines(&body));
        new_textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED));
        new_textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
        doc.textarea = new_textarea;
        doc.dirty = true;
        doc.scroll_row = 0;
        doc.scroll_col = 0;
        doc.last_activity = std::time::Instant::now();
        // saved_lines stays at the previously-saved on-disk version, so the
        // snapshot text shows as "added" (bold) until the user accepts it
        // by hitting Ctrl+S.
        self.modal = Modal::None;
        self.change_focus(Focus::Editor);
        let safety_msg = match pre_restore_id {
            Some(id) => format!(" · safety snapshot {} created", id.simple()),
            None => String::new(),
        };
        self.status = format!(
            "loaded snapshot from {} — bold marks the change vs saved{}",
            when.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %z"),
            safety_msg,
        );
    }

    /// Open the snapshot-diff modal against the cursor's snapshot.
    /// Stashes the current `SnapshotPicker` modal inside the new
    /// variant so `Esc` returns to the picker rather than closing
    /// both layers.
    pub(super) fn open_snapshot_diff(&mut self) {
        let (snap_id, when, paragraph_title) = match &self.modal {
            Modal::SnapshotPicker {
                snapshots,
                cursor,
                paragraph_title,
                ..
            } => {
                // 1.2.8+ — translate visible cursor → absolute index.
                let visible = self.visible_snapshot_indices(snapshots);
                let Some(abs_idx) = visible.get(*cursor) else {
                    return;
                };
                let Some(snap) = snapshots.get(*abs_idx) else {
                    return;
                };
                (snap.id, snap.created_at, paragraph_title.clone())
            }
            _ => return,
        };
        let snapshot_bytes = match self.store.snapshot_content(snap_id) {
            Ok(Some(b)) => b,
            Ok(None) => {
                self.status = "snapshot has no body".into();
                return;
            }
            Err(e) => {
                self.status = format!("snapshot load failed: {e}");
                return;
            }
        };
        let snapshot_text = String::from_utf8_lossy(&snapshot_bytes).into_owned();
        let current_text = self
            .opened
            .as_ref()
            .map(|d| d.textarea.lines().join("\n"))
            .unwrap_or_default();
        let rows = compute_line_diff(&snapshot_text, &current_text);
        let when_str = when
            .with_timezone(&chrono::Local)
            .format("%Y-%m-%d %H:%M:%S %z")
            .to_string();
        let return_to = Box::new(std::mem::replace(&mut self.modal, Modal::None));
        self.modal = Modal::SnapshotDiff {
            paragraph_title,
            when: when_str,
            rows,
            scroll: 0,
            return_to,
        };
        self.status = "diff: snapshot ← left · current → right · ↑↓ scroll · Esc back".into();
    }

    pub(super) fn delete_current_snapshot(&mut self) {
        // 1.2.8+ — translate visible cursor → absolute index
        // via the annotation-filter helper, same as the load
        // path.
        let (snap_id, when, paragraph_id, paragraph_title) = match &self.modal {
            Modal::SnapshotPicker {
                snapshots,
                cursor,
                paragraph_id,
                paragraph_title,
            } => {
                let visible = self.visible_snapshot_indices(snapshots);
                let Some(abs_idx) = visible.get(*cursor) else {
                    return;
                };
                let Some(snap) = snapshots.get(*abs_idx).cloned() else {
                    return;
                };
                (snap.id, snap.created_at, *paragraph_id, paragraph_title.clone())
            }
            _ => return,
        };

        if let Err(e) = self.store.delete_snapshot(snap_id) {
            self.status = format!("delete snapshot failed: {e}");
            return;
        }

        let when_local = when
            .with_timezone(&chrono::Local)
            .format("%Y-%m-%d %H:%M:%S %z");

        match self.store.list_snapshots(paragraph_id) {
            Ok(snapshots) => {
                if snapshots.is_empty() {
                    self.modal = Modal::None;
                    self.status = format!(
                        "deleted snapshot {when_local} — no snapshots left for `{paragraph_title}`"
                    );
                } else {
                    // Keep the cursor on the same row index, clamped
                    // to the new (shorter) list — feels like "the row
                    // below the deleted one slid up".
                    let new_cursor = match &self.modal {
                        Modal::SnapshotPicker { cursor, .. } => {
                            (*cursor).min(snapshots.len() - 1)
                        }
                        _ => 0,
                    };
                    self.modal = Modal::SnapshotPicker {
                        paragraph_id,
                        paragraph_title,
                        snapshots,
                        cursor: new_cursor,
                    };
                    self.status = format!("deleted snapshot {when_local}");
                }
            }
            Err(e) => {
                self.modal = Modal::None;
                self.status =
                    format!("deleted snapshot, but couldn't refresh list: {e}");
            }
        }
    }

}

/// Indices into `entries` matching the browser filter (case-
/// insensitive substring against either the paragraph title or the
/// snapshot annotation). Empty filter returns every index in order.
pub(super) fn browser_visible_indices(
    entries: &[(String, crate::store::Snapshot)],
    filter: &str,
) -> Vec<usize> {
    if filter.is_empty() {
        return (0..entries.len()).collect();
    }
    let needle = filter.to_lowercase();
    entries
        .iter()
        .enumerate()
        .filter(|(_, (title, s))| {
            title.to_lowercase().contains(&needle)
                || s.annotation.to_lowercase().contains(&needle)
        })
        .map(|(i, _)| i)
        .collect()
}

#[cfg(test)]
mod browser_tests {
    use super::browser_visible_indices;
    use crate::store::Snapshot;
    use uuid::Uuid;

    fn snap(annotation: &str) -> Snapshot {
        Snapshot {
            id: Uuid::new_v4(),
            parent_id: Uuid::new_v4(),
            created_at: chrono::Utc::now(),
            word_count: 0,
            preview: String::new(),
            annotation: annotation.to_string(),
        }
    }

    #[test]
    fn empty_filter_returns_all_in_order() {
        let entries = vec![
            ("Chapter One".into(), snap("first draft")),
            ("Chapter Two".into(), snap("revision")),
        ];
        assert_eq!(browser_visible_indices(&entries, ""), vec![0, 1]);
    }

    #[test]
    fn filter_matches_title_or_annotation_case_insensitively() {
        let entries = vec![
            ("The Long Road".into(), snap("first draft")),
            ("Opening".into(), snap("REVISION pass")),
            ("Closing".into(), snap("typo fix")),
        ];
        // Title match.
        assert_eq!(browser_visible_indices(&entries, "long"), vec![0]);
        // Annotation match, case-insensitive.
        assert_eq!(browser_visible_indices(&entries, "revision"), vec![1]);
        // No match.
        assert!(browser_visible_indices(&entries, "zzz").is_empty());
    }
}
