//! Core of the Kimün note-taking app: all file operations, indexing, and
//! note manipulation, with no presentation concerns.
//!
//! The public surface is the [`NoteVault`] facade. A vault is a directory of
//! Markdown notes on disk; `NoteVault` owns the operations over it (create,
//! read, write, rename, search, browse) and is cheap to clone.
//!
//! A few conventions shape this crate:
//!
//! - **`VaultPath` for vault-internal paths.** Everything addressed *inside*
//!   the vault uses [`VaultPath`], a case-insensitive,
//!   `/`-separated, OS-agnostic path. OS path types are reserved for
//!   configuration values (the workspace root) and for converting back to a
//!   real filesystem location when a caller needs one.
//! - **The index is a cache.** Search, backlinks, suggestions, and listings
//!   are served from a SQLite index that mirrors the notes on disk. It can be
//!   rebuilt at any time from the files, which remain the source of truth.
//! - **`nfs` owns the filesystem.** Every direct `std::fs`/`tokio::fs` call
//!   lives in the [`nfs`] module; the rest of the crate goes through it.
//!
//! # Example
//!
//! Create a vault in a temporary directory, write a note, and read it back:
//!
//! ```no_run
//! use kimun_core::{NoteVault, VaultConfig};
//! use kimun_core::nfs::VaultPath;
//!
//! let dir = tempfile::tempdir().unwrap();
//! let rt = tokio::runtime::Runtime::new().unwrap();
//! rt.block_on(async {
//!     let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
//!     let path = VaultPath::new("/hello.md");
//!     vault.create_note(&path, "# Hello\n").await.unwrap();
//!     let text = vault.get_note_text(&path).await.unwrap();
//!     assert_eq!(text, "# Hello\n");
//! });
//! ```

/// Error types returned across the crate's public API.
pub mod error;
pub(crate) mod index;
pub(crate) mod link_rewrite;
/// Filesystem layer: the only place that touches the OS filesystem directly,
/// plus the [`VaultPath`] vault-internal path type.
pub mod nfs;
/// Note model: parsing Markdown into details, chunks, links, and tags.
pub mod note;
pub(crate) mod sync;
/// Small standalone helpers (paths, log directory, diacritic folding).
pub mod utilities;
pub use index::search_terms::{
    expand_bare_note_prefixes, query_has_unterminated_quote, query_token_spans, quote_query_term,
    strip_order_directive, with_order_directive, OrderBy, OrderField, QueryTokenClass,
    QueryTokenSpan, SearchTerms,
};
pub use index::{IndexDiff, NoteSuggestion, TagSuggestion};
pub use nfs::saved_searches::{saved_search_name_matches, SavedSearch};
pub use utilities::{app_log_dir, ensure_dir_exists};

use std::{
    collections::HashMap,
    fmt::Display,
    path::{Path, PathBuf},
    sync::{
        mpsc::{Receiver, Sender},
        Arc,
    },
    time::{Duration, SystemTime},
};

use chrono::{NaiveDate, Utc};
use error::{FSError, VaultError};
use index::NoteIndex;
use link_rewrite::LinkRewrite;
use log::debug;
use nfs::{NoteEntryData, VaultPath};
use note::{ContentChunk, NoteContentData, NoteDetails};
use sync::VaultSync;
use utilities::path_to_string;

use crate::nfs::saved_searches;
use crate::nfs::DirectoryEntryData;

/// Default directory for journal entries, one note per day.
pub const DEFAULT_JOURNAL_PATH: &str = "/journal";
/// Default directory for quick-capture notes (see [`NoteVault::quick_note`]).
pub const DEFAULT_INBOX_PATH: &str = "/inbox";
/// Default directory for attachments (see
/// [`NoteVault::default_attachments_path`]).
pub const DEFAULT_ASSETS_PATH: &str = "/assets";

/// Timing summary of an indexing pass.
pub struct IndexReport {
    /// When the pass started.
    pub start: SystemTime,
    /// How long the pass took (zero until the pass finishes).
    pub duration: Duration,
}

impl IndexReport {
    fn new() -> Self {
        let start = SystemTime::now();
        Self {
            start,
            duration: Duration::default(),
        }
    }

    fn finish(&mut self) {
        let time = SystemTime::now();
        let duration = time.duration_since(self.start).unwrap_or_default();
        self.duration = duration;
    }
}

/// Configuration passed to [`NoteVault::new`].
///
/// `workspace_path` is the OS path to the vault's root directory.
/// `db_path` overrides where the SQLite cache is stored. When `None`,
/// the cache lives at `<workspace_path>/kimun.sqlite` (legacy default).
#[derive(Debug, Clone)]
pub struct VaultConfig {
    /// OS path to the vault's root directory.
    pub workspace_path: std::path::PathBuf,
    /// Override for the SQLite cache location. When `None`, the cache lives at
    /// `<workspace_path>/kimun.sqlite` (legacy default).
    pub db_path: Option<std::path::PathBuf>,
    /// When `true`, destructive automated edits (overwrite, replace, delete, and
    /// the backlink rewrites of rename/move) copy a note's previous content into
    /// a hidden in-vault backup directory before mutating it. The TUI leaves this
    /// off; the CLI and MCP server turn it on.
    pub backup: bool,
}

impl VaultConfig {
    /// Builds a config for the vault rooted at `workspace_path`, with the
    /// legacy default cache location and backups disabled.
    pub fn new(workspace_path: impl Into<std::path::PathBuf>) -> Self {
        Self {
            workspace_path: workspace_path.into(),
            db_path: None,
            backup: false,
        }
    }

    /// Overrides where the SQLite cache is stored, instead of the legacy
    /// in-workspace default.
    pub fn with_db_path(mut self, db_path: impl Into<std::path::PathBuf>) -> Self {
        self.db_path = Some(db_path.into());
        self
    }

    /// Enables or disables backing up note content before destructive edits
    /// (see the [`backup`](Self::backup) field).
    pub fn with_backup(mut self, backup: bool) -> Self {
        self.backup = backup;
        self
    }
}

/// Result of a dry-run replace ([`NoteVault::preview_replace`]): how many matches
/// would be replaced, and the note's content after the replacement. Nothing is
/// written to disk.
#[derive(Debug, Clone)]
pub struct ReplacePreview {
    /// Number of matches that would be replaced.
    pub count: usize,
    /// The note's content after the replacement.
    pub content: String,
}

/// Facade over a vault: a directory of Markdown notes plus its searchable
/// index. Cheap to clone — clones share the index pool and per-note locks.
#[derive(Debug, Clone)]
pub struct NoteVault {
    /// Stored as `Arc<Path>` (not `Arc<PathBuf>`) because (a) it impls
    /// `AsRef<Path>` directly so it can be passed to nfs helpers without
    /// extra deref, (b) `Arc::clone` is a refcount bump for fan-out tasks
    /// (backlink rewrites, indexing).
    workspace_path: Arc<Path>,
    journal_path: VaultPath,
    inbox_path: VaultPath,
    /// The vault's searchable note index. Crate-visible so the index module's
    /// own tests can exercise vault-level flows against index internals.
    pub(crate) index: NoteIndex,
    /// Whether destructive writes back up the previous content first. Mirrors
    /// [`VaultConfig::backup`]; see its docs.
    backup: bool,
    /// Per-note in-process write locks. Concurrent content mutations to the same
    /// note (e.g. parallel MCP tool calls) serialize on these so a read-modify-
    /// write like `replace` can't lose an update. Shared across clones via `Arc`.
    /// Grows with the number of distinct notes mutated this process; entries are
    /// tiny.
    note_locks: Arc<std::sync::Mutex<HashMap<VaultPath, Arc<tokio::sync::Mutex<()>>>>>,
}

// SqlitePool doesn't implement PartialEq; two vaults are equivalent when they
// point at the same workspace.
impl PartialEq for NoteVault {
    fn eq(&self, other: &Self) -> bool {
        self.workspace_path == other.workspace_path
    }
}

impl NoteVault {
    /// Creates a new instance of the Note Vault.
    /// Make sure you call `NoteVault::validate_and_init(&self)` to initialize the DB index if
    /// needed.
    pub async fn new(config: VaultConfig) -> Result<Self, VaultError> {
        debug!("Creating new vault Instance");
        let backup = config.backup;
        let workspace_path = config.workspace_path;
        if !workspace_path.exists() {
            return Err(VaultError::VaultPathNotFound {
                path: path_to_string(&workspace_path),
            })?;
        }
        if !workspace_path.is_dir() {
            return Err(VaultError::FSError(FSError::InvalidPath {
                path: path_to_string(&workspace_path),
                message: "Path provided is not a directory".to_string(),
            }))?;
        };

        let db_path = config
            .db_path
            .unwrap_or_else(|| workspace_path.join(crate::index::DB_FILE));
        let index = NoteIndex::open(&db_path).await?;
        let note_vault = Self {
            workspace_path: Arc::from(workspace_path.as_path()),
            journal_path: VaultPath::new(DEFAULT_JOURNAL_PATH),
            inbox_path: VaultPath::new(DEFAULT_INBOX_PATH),
            index,
            backup,
            note_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
        };
        Ok(note_vault)
    }

    /// OS path to the workspace root (filesystem root of this vault).
    pub fn workspace_path(&self) -> &Path {
        &self.workspace_path
    }

    /// `false` when opening the vault self-healed the index schema (missing,
    /// outdated, or invalid), meaning the index is valid but
    /// empty until a sync pass ([`validate_and_init`](Self::validate_and_init))
    /// fills it. Fast paths use this to refuse to operate against an empty
    /// index without paying for a sync.
    pub fn index_ready(&self) -> bool {
        self.index.ready()
    }

    /// Walks the entire vault checking for case-insensitive name collisions.
    /// Runs on a blocking thread because it does synchronous filesystem I/O.
    async fn fail_on_case_conflicts(&self) -> Result<(), VaultError> {
        let workspace = self.workspace_path.clone();
        let conflicts = tokio::task::spawn_blocking(move || nfs::check_case_conflicts(&workspace))
            .await
            .map_err(|e| VaultError::TaskJoin(format!("case-conflict scan: {}", e)))?;
        if !conflicts.is_empty() {
            return Err(VaultError::CaseConflict { conflicts });
        }
        Ok(())
    }
    /// Brings the index in step with the vault on disk. Opening the vault
    /// already self-healed the index schema, so all that remains
    /// is a sync pass: a quick existence scan when the index was already
    /// current, or a full scan when it was just healed (and is thus empty).
    /// This can be slow on large vaults.
    pub async fn validate_and_init(&self) -> Result<IndexReport, VaultError> {
        self.fail_on_case_conflicts().await?;
        if self.index.ready() {
            // We only check if there are new notes
            self.index_notes(NotesValidation::None).await
        } else {
            debug!("Index was healed on open — running a full sync");
            self.index_notes(NotesValidation::Full).await
        }
    }

    /// Deletes all the cached data from the index by recreating its schema,
    /// then rebuilds it with a full sync pass.
    pub async fn recreate_index(&self) -> Result<IndexReport, VaultError> {
        self.fail_on_case_conflicts().await?;
        let index_report = IndexReport::new();
        debug!("Recreating index from Vault request");
        self.index.recreate().await?;
        debug!("Tables created, creating index");
        self.int_index_notes(index_report, NotesValidation::Full)
            .await
    }

    /// Traverses the whole vault directory and verifies the notes to
    /// update the cached data in the DB. The validation is defined by
    /// the validation mode:
    ///
    /// NotesValidation::Full Checks the content of the note by comparing a hash based on the text
    /// conatined in the file.
    /// NotesValidation::Fast Checks the size of the file to identify if the note has changed and
    /// then update the DB entry.
    /// NotesValidation::None Checks if the note exists or not.
    pub async fn index_notes(
        &self,
        validation_mode: NotesValidation,
    ) -> Result<IndexReport, VaultError> {
        let index_report = IndexReport::new();
        self.int_index_notes(index_report, validation_mode).await
    }

    async fn int_index_notes(
        &self,
        mut index_report: IndexReport,
        validation_mode: NotesValidation,
    ) -> Result<IndexReport, VaultError> {
        VaultSync::new(&self.index, self.workspace_path())
            .run(&VaultPath::root(), true, validation_mode, None)
            .await?;
        // A whole-vault sync just completed: the index mirrors the disk, so
        // the readiness probe reports true even when this instance healed or
        // recreated the schema earlier.
        self.index.mark_synced();
        index_report.finish();
        debug!("TIME: {}", index_report.duration.as_secs());
        Ok(index_report)
    }

    /// Returns true if the path resolves to anything (note, directory, attachment)
    /// on disk. Cheaper than loading the full entry when only existence matters.
    pub async fn exists(&self, path: &VaultPath) -> bool {
        nfs::path_exists(self.workspace_path(), path)
            .await
            .unwrap_or(false)
    }

    /// Directory under which journal entries are created.
    pub fn journal_path(&self) -> &VaultPath {
        &self.journal_path
    }

    /// Directory under which quick-capture notes are created.
    pub fn inbox_path(&self) -> &VaultPath {
        &self.inbox_path
    }

    /// Overrides the inbox directory used by [`Self::quick_note`].
    pub fn set_inbox_path(&mut self, path: VaultPath) {
        self.inbox_path = path;
    }

    /// Creates a timestamped note under the inbox directory. On name collision
    /// (including TOCTOU between the in-memory probe and the FS create), tries
    /// the next suffix up to `-99`. The retry loop calls `create_note`
    /// directly so each iteration's existence check is the atomic
    /// `O_EXCL` open inside `nfs::create_note_exclusive`.
    pub async fn quick_note(&self, text: &str) -> Result<NoteDetails, VaultError> {
        let base_name = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
        let candidate = |name: &str| {
            self.inbox_path
                .append(&VaultPath::note_path_from(name))
                .absolute()
        };

        for attempt in 0..=99 {
            let path = if attempt == 0 {
                candidate(&base_name)
            } else if attempt == 1 {
                continue; // attempts are labelled `name`, `name-2`, … `name-99`
            } else {
                candidate(&format!("{}-{}", base_name, attempt))
            };
            match self.create_note(&path, text).await {
                Ok(_) => return Ok(NoteDetails::new(&path, text)),
                Err(VaultError::NoteExists { .. }) => continue,
                Err(e) => return Err(e),
            }
        }

        let placeholder = candidate(&base_name);
        Err(VaultError::FSError(FSError::InvalidPath {
            path: placeholder.to_string(),
            message: "Could not find a free quick note name".to_string(),
        }))
    }

    /// Loads today's journal entry, creating it with a date heading if it does
    /// not exist yet. Returns the note details, its content, and `true` when the
    /// entry was freshly created.
    pub async fn journal_entry(&self) -> Result<(NoteDetails, String, bool), VaultError> {
        let (title, note_path) = self.get_todays_journal();
        let (content, created) = self
            .load_or_create_note(&note_path, Some(format!("# {}\n\n", title)))
            .await?;
        let details = NoteDetails::new(&note_path, &content);
        Ok((details, content, created))
    }

    fn get_todays_journal(&self) -> (String, VaultPath) {
        let today = Utc::now();
        let today_string = today.format("%Y-%m-%d").to_string();

        (
            today_string.clone(),
            self.journal_path
                .append(&VaultPath::note_path_from(&today_string))
                .absolute(),
        )
    }

    /// Parses the date out of a journal note path, or `None` when `note_path`
    /// is not a `YYYY-MM-DD` note directly under the journal directory.
    pub fn journal_date(&self, note_path: &VaultPath) -> Option<NaiveDate> {
        if !note_path.is_note() {
            return None;
        }

        let (parent, _) = note_path.get_parent_path();
        if parent.eq(&self.journal_path) {
            let name = note_path.get_clean_name();
            NaiveDate::parse_from_str(&name, "%Y-%m-%d").ok()
        } else {
            None
        }
    }

    /// Loads the note at `path` if it exists; otherwise creates it with `default_text`
    /// (or empty if `None`) and returns that text.
    /// Returns the note's text and `true` when the note had to be created (it
    /// did not exist yet), so callers can react to a fresh note — e.g. refresh
    /// a directory listing.
    pub async fn load_or_create_note(
        &self,
        path: &VaultPath,
        default_text: Option<String>,
    ) -> Result<(String, bool), VaultError> {
        match nfs::load_note(self.workspace_path(), path).await {
            Ok(text) => Ok((text, false)),
            Err(e) if e.is_not_found() => {
                let text = default_text.unwrap_or_default();
                self.create_note(path, &text).await?;
                Ok((text, true))
            }
            Err(e) => Err(e.into()),
        }
    }

    /// Loads the raw text of the note at `path`.
    ///
    /// When the file doesn't exist you get a [`VaultError::FSError`] wrapping
    /// `FSError::NotePathNotFound`; you can branch on that to lazily create a
    /// note, or use [`Self::load_or_create_note`] instead.
    pub async fn get_note_text(&self, path: &VaultPath) -> Result<String, VaultError> {
        let text = nfs::load_note(self.workspace_path(), path).await?;
        Ok(text)
    }

    /// Loads a note as [`NoteDetails`], carrying its path, raw text, and parsed
    /// metadata.
    ///
    /// Missing-file behaviour matches [`Self::get_note_text`].
    pub async fn load_note(&self, path: &VaultPath) -> Result<NoteDetails, VaultError> {
        let text = self.get_note_text(path).await?;
        Ok(NoteDetails::new(path, text))
    }

    /// Returns the indexed content chunks for the note at `path`, keyed by the
    /// note path they belong to.
    pub async fn get_note_chunks(
        &self,
        path: &VaultPath,
    ) -> Result<HashMap<VaultPath, Vec<ContentChunk>>, VaultError> {
        let a = self.index.get_notes_sections(path, false).await?;
        Ok(a)
    }

    /// Searches notes using the vault's query syntax (see [`SearchTerms`]).
    /// Returns each matching note's entry and content data.
    pub async fn search_notes<S: AsRef<str>>(
        &self,
        search_query: S,
    ) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
        let search_query = search_query.as_ref();
        let a = self.index.search(search_query).await?;
        Ok(a)
    }

    /// Returns every distinct label persisted in the vault, lowercased.
    pub async fn list_labels(&self) -> Result<Vec<String>, VaultError> {
        Ok(self.index.list_labels().await?)
    }

    /// Returns notes whose name (filename without extension) starts with
    /// `prefix`, case-insensitive, capped at `limit`. Used to feed the
    /// wikilink autocomplete popup — note that the inserted wikilink target
    /// is the `name` field, not the `path`.
    pub async fn suggest_notes_by_prefix(
        &self,
        prefix: &str,
        limit: usize,
    ) -> Result<Vec<NoteSuggestion>, VaultError> {
        Ok(self.index.suggest_notes_by_prefix(prefix, limit).await?)
    }

    /// Returns tag labels matching `prefix` (case-insensitive) paired with
    /// usage counts, capped at `limit`. Used to feed the hashtag autocomplete
    /// popup in both the editor and the search box.
    pub async fn suggest_tags_by_prefix(
        &self,
        prefix: &str,
        limit: usize,
    ) -> Result<Vec<TagSuggestion>, VaultError> {
        Ok(self.index.suggest_tags_by_prefix(prefix, limit).await?)
    }

    /// Returns every distinct label in the vault paired with the number of
    /// notes carrying it. Labels are returned sorted alphabetically.
    pub async fn label_counts(&self) -> Result<Vec<(String, usize)>, VaultError> {
        let rows = self.index.label_counts().await?;
        Ok(rows.into_iter().map(|(n, c)| (n, c as usize)).collect())
    }

    /// Returns every note path that carries the given label. The label
    /// argument is lowercased before lookup, matching how labels are stored.
    pub async fn notes_with_label<S: AsRef<str>>(
        &self,
        name: S,
    ) -> Result<Vec<VaultPath>, VaultError> {
        Ok(self.index.notes_with_label(name.as_ref()).await?)
    }

    /// Get notes under the given path. When `recursive` is false, only direct
    /// children are returned.
    pub async fn get_notes(
        &self,
        path: &VaultPath,
        recursive: bool,
    ) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
        let notes = self.index.get_notes(path, recursive).await?;
        Ok(notes)
    }

    /// Returns every note in the vault with its entry and content data.
    pub async fn get_all_notes(&self) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
        let a = self.index.get_all_notes().await?;
        Ok(a)
    }
    /// Resolves a vault path to its real OS filesystem location under the
    /// workspace root.
    pub fn path_to_pathbuf(&self, path: &VaultPath) -> PathBuf {
        path.to_pathbuf(self.workspace_path())
    }

    /// Walks the vault per `options`, streaming each entry as a
    /// [`SearchResult`] through the channel set up by
    /// [`VaultBrowseOptionsBuilder::build`]. A recursive browse from the root
    /// doubles as a full index sync.
    pub async fn browse_vault(&self, options: VaultBrowseOptions) -> Result<(), VaultError> {
        let start = std::time::SystemTime::now();
        debug!("> Start fetching files with Options:\n{}", options);

        VaultSync::new(&self.index, self.workspace_path())
            .run(
                &options.path,
                options.recursive,
                options.validation,
                Some(options.sender.clone()),
            )
            .await?;

        // A recursive browse from the root is a whole-vault sync: the index
        // now mirrors the disk, so the readiness probe must report true —
        // same as int_index_notes. A partial-subtree browse must NOT mark
        // synced (the rest of a healed index could still be empty).
        if options.recursive && options.path.is_root_or_empty() {
            self.index.mark_synced();
        }

        let time = std::time::SystemTime::now()
            .duration_since(start)
            .expect("Something's wrong with the time");
        debug!("> Files fetched in {} milliseconds", time.as_millis());

        Ok(())
    }

    /// Returns all subdirectories under `path`.
    /// Non-recursive returns only the immediate children; recursive returns the full tree.
    pub fn get_directories(
        &self,
        path: &VaultPath,
        recursive: bool,
    ) -> Result<Vec<DirectoryDetails>, VaultError> {
        Ok(nfs::list_directories(
            self.workspace_path(),
            path,
            recursive,
        )?)
    }

    /// Converts a note's raw Markdown into rendered Markdown and extracts all links.
    ///
    /// - WikiLinks (`[[note]]`) are converted to standard Markdown links.
    /// - Note links are resolved to vault-relative absolute paths.
    /// - Hashtags become Markdown links (`[#tag](#tag)`) and are added to the links list.
    /// - Image paths are resolved to absolute OS paths so renderers can load them directly.
    ///   Relative image paths are resolved against the note's location in the vault.
    ///   External image URLs are kept as-is.
    pub async fn get_markdown_and_links(
        &self,
        path: &VaultPath,
    ) -> Result<note::MarkdownNote, VaultError> {
        let note = self.load_note(path).await?;
        let note_parent = if note.path.is_note() {
            note.path.get_parent_path().0
        } else {
            note.path.clone()
        };
        let (md_text, mut links) = note.get_markdown_and_links();
        // Since this function is intended to return content ready to be rendered
        // We need the full path of the image links, so any markdown processor can find the image,
        // the full path can only be resolved from here as we have the vault path
        let (md_text, image_links) = note::process_image_links(&md_text, |alt_text, raw_path| {
            let resolved = if note::scan::is_remote_url(raw_path) {
                raw_path.to_string()
            } else {
                let image_vault_path = if raw_path.starts_with('/') {
                    VaultPath::new(raw_path)
                } else {
                    note_parent.append(&VaultPath::new(raw_path)).flatten()
                };
                image_vault_path
                    .to_pathbuf(self.workspace_path())
                    .display()
                    .to_string()
            };
            let link = note::NoteLink::image(&resolved, alt_text, raw_path);
            (resolved, link)
        });
        links.extend(image_links);
        Ok(note::MarkdownNote {
            text: md_text,
            links,
        })
    }

    /// Returns all notes that contain a link pointing to `path`.
    /// Matches both absolute vault paths and bare filename links (wikilinks).
    pub async fn get_backlinks(
        &self,
        path: &VaultPath,
    ) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
        Ok(self.index.get_backlinks(path).await?)
    }

    /// List the vault's saved searches (see `SavedSearch`). Empty if none.
    pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearch>, VaultError> {
        Ok(saved_searches::read_saved_searches(self.workspace_path()).await?)
    }

    /// Saved searches whose name starts with `prefix` (case-insensitive,
    /// ASCII folding to match [`Self::save_search`]/[`Self::delete_saved_search`]), in
    /// stored order, capped at `limit`. Feeds the `?`-prefix autocomplete in
    /// the query input — mirrors [`Self::suggest_notes_by_prefix`] /
    /// [`Self::suggest_tags_by_prefix`] so prefix matching stays in core. Saved
    /// searches are file-backed (not indexed), so this reads the small TOML
    /// file; it is the single place to add caching if it ever gets hot.
    pub async fn suggest_saved_searches_by_prefix(
        &self,
        prefix: &str,
        limit: usize,
    ) -> Result<Vec<SavedSearch>, VaultError> {
        let prefix = prefix.to_ascii_lowercase();
        let mut matches = saved_searches::read_saved_searches(self.workspace_path()).await?;
        matches.retain(|s| s.name.to_ascii_lowercase().starts_with(&prefix));
        matches.truncate(limit);
        Ok(matches)
    }

    /// Insert or replace a saved search by name (case-insensitive match,
    /// preserving the existing position on overwrite). Appends if new.
    pub async fn save_search(&self, name: &str, query: &str) -> Result<(), VaultError> {
        let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
        let entry = SavedSearch {
            name: name.to_string(),
            query: query.to_string(),
        };
        match all
            .iter_mut()
            .find(|s| saved_search_name_matches(&s.name, name))
        {
            Some(existing) => *existing = entry,
            None => all.push(entry),
        }
        saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
        Ok(())
    }

    /// Delete a saved search by name (case-insensitive). No-op if absent.
    pub async fn delete_saved_search(&self, name: &str) -> Result<(), VaultError> {
        let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
        all.retain(|s| !saved_search_name_matches(&s.name, name));
        saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
        Ok(())
    }

    /// Rename a saved search, preserving its position and query. No-op if absent.
    pub async fn rename_saved_search(&self, old: &str, new: &str) -> Result<(), VaultError> {
        let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
        if let Some(existing) = all
            .iter_mut()
            .find(|s| saved_search_name_matches(&s.name, old))
        {
            existing.name = new.to_string();
        }
        saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
        Ok(())
    }

    /// Creates a new note at `path` with `text`, failing with
    /// [`VaultError::NoteExists`] if a note is already there. The create is
    /// exclusive (atomic `O_EXCL`), so it never clobbers an existing file.
    /// The new note is indexed before returning.
    pub async fn create_note<S: AsRef<str>>(
        &self,
        path: &VaultPath,
        text: S,
    ) -> Result<(NoteEntryData, NoteContentData), VaultError> {
        let entry_data = nfs::create_note_exclusive(self.workspace_path(), path, &text)
            .await
            .map_err(|e| match e {
                FSError::AlreadyExists { path } => VaultError::NoteExists { path },
                other => VaultError::FSError(other),
            })?;
        let note_details = NoteDetails::new(path, text);
        let content_data = self.index.save_note(&entry_data, &note_details).await?;
        Ok((entry_data, content_data))
    }

    /// Creates a directory at `path`, failing with
    /// [`VaultError::DirectoryExists`] if one is already there.
    pub async fn create_directory(
        &self,
        path: &VaultPath,
    ) -> Result<DirectoryEntryData, VaultError> {
        nfs::create_directory(self.workspace_path(), path)
            .await
            .map_err(|e| match e {
                FSError::AlreadyExists { path } => VaultError::DirectoryExists { path },
                other => VaultError::FSError(other),
            })
    }

    /// Backs up the current content of `path` when this vault was opened with
    /// backups enabled (CLI/MCP), and is a no-op otherwise (TUI). Called before
    /// any destructive write so the previous content stays recoverable.
    async fn backup_if_enabled(&self, path: &VaultPath) -> Result<(), VaultError> {
        if self.backup {
            nfs::backup_note(self.workspace_path(), path).await?;
        }
        Ok(())
    }

    /// Acquires the per-note write lock, serializing content mutations to `path`
    /// within this process so a read-modify-write (e.g. `replace`) can't be
    /// interleaved by another in-process writer. Cross-process writers are not
    /// covered — a local single-user vault rarely sees that, and backups make any
    /// clobbered version recoverable.
    async fn lock_note(&self, path: &VaultPath) -> tokio::sync::OwnedMutexGuard<()> {
        let key = path.flatten();
        let lock = {
            let mut map = self.note_locks.lock().unwrap();
            map.entry(key)
                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
                .clone()
        };
        lock.lock_owned().await
    }

    /// Acquires the per-note locks for several notes at once, in a stable
    /// (sorted, deduped) order so concurrent multi-note operations (e.g. two
    /// renames with overlapping victims) can't deadlock. Hold the returned
    /// guards for the duration of the operation.
    async fn lock_notes<'a>(
        &self,
        paths: impl IntoIterator<Item = &'a VaultPath>,
    ) -> Vec<tokio::sync::OwnedMutexGuard<()>> {
        let mut keys: Vec<VaultPath> = paths.into_iter().map(|p| p.flatten()).collect();
        keys.sort();
        keys.dedup();
        let mut guards = Vec::with_capacity(keys.len());
        for key in &keys {
            guards.push(self.lock_note(key).await);
        }
        guards
    }

    /// Appends `text` to the note at `path`, creating it (with `default`
    /// content) when absent. The read and the write run under the per-note lock
    /// so concurrent appends to the same note can't lose an update.
    pub async fn append_to_note(
        &self,
        path: &VaultPath,
        text: &str,
        default: Option<String>,
    ) -> Result<(), VaultError> {
        let _guard = self.lock_note(path).await;
        let (existing, _created) = self.load_or_create_note(path, default).await?;
        let combined = if existing.is_empty() {
            text.to_string()
        } else {
            format!("{existing}\n{text}")
        };
        self.save_note_unlocked(path, combined).await?;
        Ok(())
    }

    /// Writes `text` to the note at `path`, overwriting any existing content
    /// (backing it up first when backups are enabled), and re-indexes the note.
    /// Serialized per note via the per-note write lock.
    pub async fn save_note<S: AsRef<str>>(
        &self,
        path: &VaultPath,
        text: S,
    ) -> Result<(NoteEntryData, NoteContentData), VaultError> {
        let _guard = self.lock_note(path).await;
        self.save_note_unlocked(path, text).await
    }

    /// Like [`save_note`] but assumes the caller already holds the per-note lock
    /// (see [`lock_note`]). Used by read-modify-write operations such as
    /// [`replace_in_note`] that hold the lock across both the read and the write.
    async fn save_note_unlocked<S: AsRef<str>>(
        &self,
        path: &VaultPath,
        text: S,
    ) -> Result<(NoteEntryData, NoteContentData), VaultError> {
        self.backup_if_enabled(path).await?;
        let entry_data = nfs::save_note(self.workspace_path(), path, &text).await?;
        let note_details = NoteDetails::new(path, text);
        let content_data = self.index.save_note(&entry_data, &note_details).await?;
        Ok((entry_data, content_data))
    }

    /// Default attachments directory (e.g. `/assets`) inside the workspace.
    pub fn default_attachments_path(&self) -> VaultPath {
        VaultPath::new(DEFAULT_ASSETS_PATH)
    }

    /// Builds a candidate path for a new attachment under
    /// [`Self::default_attachments_path`], using `prefix` and `ext` plus the current
    /// unix-nanosecond timestamp for uniqueness. Nanoseconds (rather than
    /// millis) make same-instant collisions vanishingly unlikely for
    /// human-driven actions like clipboard paste.
    ///
    /// Does not check for collisions; callers that need stronger uniqueness
    /// guarantees should retry with [`Self::exists`] or use a different strategy.
    pub fn generate_attachment_path(&self, prefix: &str, ext: &str) -> VaultPath {
        let ts = SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let filename = format!("{prefix}_{ts}.{ext}");
        self.default_attachments_path()
            .append(&VaultPath::new(filename))
    }

    /// Writes an attachment (raw bytes — e.g. an encoded PNG) to `path` under
    /// the workspace. Creates parent directories as needed. The attachment is
    /// not added to the notes index.
    pub async fn save_attachment(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError> {
        nfs::save_attachment(self.workspace_path(), path, bytes).await?;
        Ok(())
    }

    /// If the path looks like a specific note (has the note extension), search by name;
    /// otherwise treat it as a directory/path query that may return many results.
    pub async fn open_or_search(
        &self,
        path: &VaultPath,
    ) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
        debug!("PATH: {}", path);
        let (_parent, name) = path.get_parent_path();
        if path.is_note_file() {
            Ok(self.index.search_note_by_name(name).await?)
        } else {
            Ok(self.index.search_note_by_path(path).await?)
        }
    }

    /// Deletes the note at `path` (backing it up first when backups are
    /// enabled). The index row is removed before the file, so the index never
    /// points at a missing file.
    pub async fn delete_note(&self, path: &VaultPath) -> Result<(), VaultError> {
        let path = path.flatten();
        path.ensure_note()?;
        let _guard = self.lock_note(&path).await;
        self.backup_if_enabled(&path).await?;

        // Delete in the index first so it never points at a missing file.
        self.index.delete_notes(std::slice::from_ref(&path)).await?;

        nfs::delete_note(self.workspace_path(), &path).await?;

        Ok(())
    }

    /// Computes the result of replacing `pattern` with `replacement` in `text`.
    /// Pure — no I/O, no locking.
    ///
    /// `pattern` is a literal substring by default. When `regex` is `true` it is
    /// a regular expression ([`regex`] crate syntax) and `replacement` may
    /// reference capture groups (`$1`, `${name}`; use `$$` for a literal `$`).
    /// Use inline flags for line/case behaviour, e.g. `(?m)`, `(?s)`, `(?i)`.
    ///
    /// When `all` is `false` the match must be unique: returns
    /// [`VaultError::ReplaceTextNotFound`] when there is no match and
    /// [`VaultError::ReplaceTextNotUnique`] when there is more than one. When
    /// `all` is `true` every match is replaced. An invalid regex yields
    /// [`VaultError::InvalidRegex`]. `path` is only used to build the errors.
    /// Returns `(match count, new content)`.
    fn compute_replacement(
        text: &str,
        pattern: &str,
        replacement: &str,
        all: bool,
        regex: bool,
        path: &VaultPath,
    ) -> Result<(usize, String), VaultError> {
        let count;
        let updated;
        if regex {
            let re = regex::Regex::new(pattern).map_err(|e| VaultError::InvalidRegex {
                pattern: pattern.to_string(),
                message: e.to_string(),
            })?;
            count = re.find_iter(text).count();
            if count == 0 {
                return Err(VaultError::ReplaceTextNotFound {
                    path: path.flatten(),
                });
            }
            if !all && count > 1 {
                return Err(VaultError::ReplaceTextNotUnique {
                    path: path.flatten(),
                });
            }
            // regex `replacen` treats a limit of 0 as "replace all".
            let limit = if all { 0 } else { 1 };
            updated = re.replacen(text, limit, replacement).into_owned();
        } else {
            count = if pattern.is_empty() {
                0
            } else {
                text.matches(pattern).count()
            };
            if count == 0 {
                return Err(VaultError::ReplaceTextNotFound {
                    path: path.flatten(),
                });
            }
            if !all && count > 1 {
                return Err(VaultError::ReplaceTextNotUnique {
                    path: path.flatten(),
                });
            }
            updated = if all {
                text.replace(pattern, replacement)
            } else {
                text.replacen(pattern, replacement, 1)
            };
        }
        Ok((count, updated))
    }

    /// Replaces occurrences of `pattern` with `replacement` in the note at
    /// `path`, writing the result. See `compute_replacement` for the matching
    /// rules (literal vs `regex`, unique-unless-`all`, capture references).
    /// Returns the number of replacements made.
    pub async fn replace_in_note(
        &self,
        path: &VaultPath,
        pattern: &str,
        replacement: &str,
        all: bool,
        regex: bool,
    ) -> Result<usize, VaultError> {
        // Hold the per-note lock across the read and the write so a concurrent
        // in-process writer can't change the note between them (lost update).
        let _guard = self.lock_note(path).await;
        let text = self.get_note_text(path).await?;
        let (count, updated) =
            Self::compute_replacement(&text, pattern, replacement, all, regex, path)?;
        // Already holding the per-note lock; use the unlocked save so we don't
        // re-acquire it (the tokio Mutex is not reentrant).
        self.save_note_unlocked(path, updated).await?;
        Ok(count)
    }

    /// Dry-run of [`Self::replace_in_note`]: computes what the note would contain after
    /// the replacement **without writing** (and without taking the write lock —
    /// the result is advisory). Returns the same errors the real replace would
    /// (not found / not unique / invalid regex).
    pub async fn preview_replace(
        &self,
        path: &VaultPath,
        pattern: &str,
        replacement: &str,
        all: bool,
        regex: bool,
    ) -> Result<ReplacePreview, VaultError> {
        let text = self.get_note_text(path).await?;
        let (count, content) =
            Self::compute_replacement(&text, pattern, replacement, all, regex, path)?;
        Ok(ReplacePreview { count, content })
    }

    /// Deletes the directory at `path` and its contents, removing the
    /// corresponding index rows first.
    pub async fn delete_directory(&self, path: &VaultPath) -> Result<(), VaultError> {
        let path = path.flatten();
        path.ensure_directory()?;

        self.index
            .delete_directories(std::slice::from_ref(&path))
            .await?;

        nfs::delete_directory(self.workspace_path(), &path).await?;

        Ok(())
    }

    /// Renames the note `from` to `to`, rewriting links to it (wikilinks,
    /// Markdown links, and the note's own self-links) in every backlinking note
    /// so they keep pointing at the renamed note. Fails if `to` already exists.
    /// Source, destination, and all link victims are locked for the whole
    /// operation so a concurrent in-process write can't interleave.
    pub async fn rename_note(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError> {
        let from = from.flatten();
        let to = to.flatten();

        // Scout the linking notes, then lock the source, the destination, and
        // every victim for the whole rename, so a concurrent in-process write
        // to any of them can't interleave with the prepare → rename → commit
        // below (lost update / stale backup). Locks are taken in a stable
        // order to stay deadlock-free.
        let scouted = LinkRewrite::new(&self.index, self.workspace_path(), self.backup)
            .scout(&from, &to)
            .await?;
        let _guards = self
            .lock_notes(
                std::iter::once(&from)
                    .chain(std::iter::once(&to))
                    .chain(scouted.victims().iter()),
            )
            .await;

        // Rewrite every victim's links in memory and back them up — no FS
        // mutation yet, so a failure here aborts cleanly.
        let prepared = scouted.prepare().await?;

        // Rename the source note on disk. If this fails, victims remain
        // untouched and the index is unchanged — clean abort.
        nfs::rename_note(self.workspace_path(), &from, &to)
            .await
            .map_err(rename_dest_err)?;

        // Write the rewritten victims and the renamed note's self-links.
        let notes_with_text = prepared.commit().await?;

        // One atomic index operation: rename the source rows + update each
        // victim's chunks/links. If this fails, FS is consistent with the
        // rename but the index is stale — next sync pass corrects.
        self.index.rename_note(&from, &to, &notes_with_text).await?;

        Ok(())
    }

    /// Renames the directory `from` to `to`, updating the index paths of all
    /// notes beneath it. Fails if `to` already exists.
    pub async fn rename_directory(
        &self,
        from: &VaultPath,
        to: &VaultPath,
    ) -> Result<(), VaultError> {
        let from = from.flatten();
        let to = to.flatten();

        nfs::rename_directory(self.workspace_path(), &from, &to)
            .await
            .map_err(rename_dest_err)?;

        self.index.rename_directory(&from, &to).await?;

        Ok(())
    }
}

fn rename_dest_err(e: FSError) -> VaultError {
    match e {
        FSError::AlreadyExists { path } => VaultError::FSError(FSError::InvalidPath {
            path: path.to_string(),
            message: "Destination path already exists".to_string(),
        }),
        other => VaultError::FSError(other),
    }
}

/// A directory entry within the vault.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct DirectoryDetails {
    /// Path of the directory inside the vault.
    pub path: VaultPath,
}

/// A single entry produced by browsing the vault: a note, directory, or
/// attachment, identified by its path.
#[derive(Debug, Clone, PartialEq)]
pub struct SearchResult {
    /// Path of the entry inside the vault.
    pub path: VaultPath,
    /// What kind of entry this is, plus any content data for notes.
    pub rtype: ResultType,
}

impl SearchResult {
    /// Builds a note result carrying its content data.
    pub fn note(path: &VaultPath, content_data: &NoteContentData) -> Self {
        Self {
            path: path.to_owned(),
            rtype: ResultType::Note(content_data.to_owned()),
        }
    }
    /// Builds a directory result.
    pub fn directory(path: &VaultPath) -> Self {
        Self {
            path: path.to_owned(),
            rtype: ResultType::Directory,
        }
    }
    /// Builds an attachment result.
    pub fn attachment(path: &VaultPath) -> Self {
        Self {
            path: path.to_owned(),
            rtype: ResultType::Attachment,
        }
    }
}

/// Kind of a [`SearchResult`].
#[derive(Debug, Clone, PartialEq)]
pub enum ResultType {
    /// A note, with its parsed content data.
    Note(NoteContentData),
    /// A directory.
    Directory,
    /// An attachment (non-note file).
    Attachment,
}

/// Builder for [`VaultBrowseOptions`]; see [`NoteVault::browse_vault`].
pub struct VaultBrowseOptionsBuilder {
    path: VaultPath,
    validation: NotesValidation,
    recursive: bool,
}

impl VaultBrowseOptionsBuilder {
    /// Starts a builder rooted at `path`, with defaults otherwise.
    pub fn new(path: &VaultPath) -> Self {
        Self::default().path(path.clone())
    }

    /// Finalizes the options and creates the channel browse results are sent
    /// through; returns the options and the receiving end.
    pub fn build(self) -> (VaultBrowseOptions, Receiver<SearchResult>) {
        let (sender, receiver) = std::sync::mpsc::channel();
        (
            VaultBrowseOptions {
                path: self.path,
                validation: self.validation,
                recursive: self.recursive,
                sender,
            },
            receiver,
        )
    }

    /// Sets the path to browse from.
    pub fn path(mut self, path: VaultPath) -> Self {
        self.path = path;
        self
    }

    /// Sets whether the browse descends into subdirectories.
    pub fn recursive(mut self, recursive: bool) -> Self {
        self.recursive = recursive;
        self
    }

    /// Sets how thoroughly each note is re-validated against the index during
    /// the browse.
    pub fn validation(mut self, validation: NotesValidation) -> Self {
        self.validation = validation;
        self
    }
}

impl Default for VaultBrowseOptionsBuilder {
    fn default() -> Self {
        Self {
            path: VaultPath::root(),
            validation: NotesValidation::None,
            recursive: false,
        }
    }
}

#[derive(Debug, Clone)]
/// Options to traverse the Notes
/// You need a sync::mpsc::Sender to use a channel to receive the entries
pub struct VaultBrowseOptions {
    path: VaultPath,
    validation: NotesValidation,
    recursive: bool,
    sender: Sender<SearchResult>,
}

impl Display for VaultBrowseOptions {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Vault Browse Options - [Path: `{}`|Validation Type: `{}`|Recursive: `{}`]",
            self.path, self.validation, self.recursive
        )
    }
}

/// How thoroughly a sync pass checks whether each note has changed before
/// updating its index entry.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NotesValidation {
    /// Compares a content hash — detects any change, at the cost of reading
    /// each file.
    Full,
    /// Compares the file size — cheaper, but misses same-size edits.
    Fast,
    /// Only checks whether the note exists.
    None,
}

impl Display for NotesValidation {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                NotesValidation::Full => "Full",
                NotesValidation::Fast => "Fast",
                NotesValidation::None => "None",
            }
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;
    use std::time::Duration;
    use tempfile::TempDir;

    // Helper: build a NoteVault pointing at a temp directory (no DB needed for pure-text tests).
    async fn make_vault(dir: &std::path::Path) -> NoteVault {
        NoteVault::new(VaultConfig::new(dir)).await.unwrap()
    }

    #[tokio::test]
    async fn get_markdown_and_links_resolves_relative_image() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;

        std::fs::create_dir_all(dir.path().join("directory")).unwrap();
        std::fs::write(dir.path().join("directory/note.md"), "![alt](../photo.png)").unwrap();

        let md_note = vault
            .get_markdown_and_links(&VaultPath::new("/directory/note.md"))
            .await
            .unwrap();

        let expected_os_path = dir.path().join("photo.png").display().to_string();
        assert_eq!(md_note.text, format!("![alt]({})", expected_os_path));
        assert_eq!(1, md_note.links.len());
        let link = &md_note.links[0];
        assert_eq!(link.ltype, note::LinkType::Image(expected_os_path));
        assert_eq!(link.text, "alt");
        assert_eq!(link.raw_link, "../photo.png");
    }

    #[tokio::test]
    async fn get_markdown_and_links_resolves_absolute_vault_image() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;

        std::fs::create_dir_all(dir.path().join("notes")).unwrap();
        std::fs::write(
            dir.path().join("notes/note.md"),
            "![banner](/assets/banner.png)",
        )
        .unwrap();

        let md_note = vault
            .get_markdown_and_links(&VaultPath::new("/notes/note.md"))
            .await
            .unwrap();

        let expected_os_path = dir
            .path()
            .join("assets")
            .join("banner.png")
            .display()
            .to_string();
        assert_eq!(md_note.text, format!("![banner]({})", expected_os_path));
        assert!(matches!(
            &md_note.links[0].ltype,
            note::LinkType::Image(p) if *p == expected_os_path
        ));
    }

    #[tokio::test]
    async fn get_markdown_and_links_keeps_external_image_url() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;

        let url = "https://example.com/img.png";
        std::fs::write(dir.path().join("note.md"), format!("![remote]({})", url)).unwrap();

        let md_note = vault
            .get_markdown_and_links(&VaultPath::new("/note.md"))
            .await
            .unwrap();

        assert_eq!(md_note.text, format!("![remote]({})", url));
        assert!(matches!(
            &md_note.links[0].ltype,
            note::LinkType::Image(p) if p == url
        ));
        assert_eq!(md_note.links[0].raw_link, url);
    }

    #[tokio::test]
    async fn get_markdown_and_links_mixed_content() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;

        std::fs::write(
            dir.path().join("note.md"),
            "[[Other Note]] [link](other.md) ![img](photo.png) #tag",
        )
        .unwrap();

        let md_note = vault
            .get_markdown_and_links(&VaultPath::new("/note.md"))
            .await
            .unwrap();

        assert_eq!(
            1,
            md_note
                .links
                .iter()
                .filter(|l| matches!(l.ltype, note::LinkType::Image(_)))
                .count()
        );
        assert_eq!(
            2,
            md_note
                .links
                .iter()
                .filter(|l| matches!(l.ltype, note::LinkType::Note(_)))
                .count()
        );
        assert_eq!(
            1,
            md_note
                .links
                .iter()
                .filter(|l| matches!(l.ltype, note::LinkType::Hashtag))
                .count()
        );
    }

    // ---- rename_note: backlink rewriting integration tests ----

    /// Create a small vault with a DB, write two notes, index them, then rename one
    /// and assert that the other note's content and DB links are updated.
    async fn setup_vault_with_notes(dir: &std::path::Path) -> NoteVault {
        let vault = NoteVault::new(VaultConfig::new(dir)).await.unwrap();
        vault.validate_and_init().await.unwrap();
        vault
    }

    #[tokio::test]
    async fn rename_note_updates_wikilink_in_backlink() {
        let dir = TempDir::new().unwrap();
        let vault = setup_vault_with_notes(dir.path()).await;

        // Create the note that will be renamed
        vault
            .save_note(&VaultPath::new("/target.md"), "# Target note")
            .await
            .unwrap();
        // Create a note that links to it via wikilink
        vault
            .save_note(
                &VaultPath::new("/referrer.md"),
                "# Referrer\nSee [[target]].",
            )
            .await
            .unwrap();

        vault
            .rename_note(
                &VaultPath::new("/target.md"),
                &VaultPath::new("/renamed.md"),
            )
            .await
            .unwrap();

        // The referrer file on disk must now use [[renamed]]
        let updated = nfs::load_note(dir.path(), &VaultPath::new("/referrer.md"))
            .await
            .unwrap();
        assert!(
            updated.contains("[[renamed]]"),
            "expected [[renamed]] in: {updated}"
        );
        assert!(
            !updated.contains("[[target]]"),
            "old wikilink still present in: {updated}"
        );
    }

    #[tokio::test]
    async fn rename_note_updates_markdown_link_in_backlink() {
        let dir = TempDir::new().unwrap();
        let vault = setup_vault_with_notes(dir.path()).await;

        vault
            .save_note(&VaultPath::new("/target.md"), "# Target note")
            .await
            .unwrap();
        vault
            .save_note(
                &VaultPath::new("/referrer.md"),
                "# Referrer\n[link](/target.md) end.",
            )
            .await
            .unwrap();

        vault
            .rename_note(
                &VaultPath::new("/target.md"),
                &VaultPath::new("/renamed.md"),
            )
            .await
            .unwrap();

        let updated = nfs::load_note(dir.path(), &VaultPath::new("/referrer.md"))
            .await
            .unwrap();
        assert!(
            updated.contains("[link](/renamed.md)"),
            "expected updated link in: {updated}"
        );
        assert!(
            !updated.contains("/target.md"),
            "old path still present in: {updated}"
        );
    }

    #[tokio::test]
    async fn rename_note_does_not_touch_unrelated_notes() {
        let dir = TempDir::new().unwrap();
        let vault = setup_vault_with_notes(dir.path()).await;

        vault
            .save_note(&VaultPath::new("/target.md"), "# Target")
            .await
            .unwrap();
        vault
            .save_note(
                &VaultPath::new("/unrelated.md"),
                "# Unrelated\nNo links here.",
            )
            .await
            .unwrap();

        vault
            .rename_note(
                &VaultPath::new("/target.md"),
                &VaultPath::new("/renamed.md"),
            )
            .await
            .unwrap();

        let unrelated = nfs::load_note(dir.path(), &VaultPath::new("/unrelated.md"))
            .await
            .unwrap();
        assert_eq!(unrelated, "# Unrelated\nNo links here.");
    }

    #[tokio::test]
    async fn rename_note_handles_self_link() {
        let dir = TempDir::new().unwrap();
        let vault = setup_vault_with_notes(dir.path()).await;

        vault
            .save_note(
                &VaultPath::new("/target.md"),
                "# Target\nSee [[target]] here.",
            )
            .await
            .unwrap();

        vault
            .rename_note(
                &VaultPath::new("/target.md"),
                &VaultPath::new("/renamed.md"),
            )
            .await
            .unwrap();

        // Source no longer exists at the old path.
        assert!(
            !dir.path().join("target.md").exists(),
            "old file should be gone"
        );
        // New file exists with the self-link rewritten.
        let body = nfs::load_note(dir.path(), &VaultPath::new("/renamed.md"))
            .await
            .unwrap();
        assert!(
            body.contains("[[renamed]]"),
            "expected self-link rewritten in: {body}"
        );
        assert!(
            !body.contains("[[target]]"),
            "old self-link still present in: {body}"
        );

        // DB should have exactly one row for the renamed note.
        let all = vault.get_all_notes().await.unwrap();
        assert_eq!(all.len(), 1, "expected single DB row, got: {:?}", all);
    }

    #[test]
    fn test_index_report_finish() {
        let mut report = IndexReport::new();

        // Sleep for a small amount to ensure duration is non-zero
        std::thread::sleep(Duration::from_millis(10));

        report.finish();

        // Check that duration is now set and non-zero
        assert!(report.duration > Duration::default());
        assert!(report.duration.as_millis() >= 10);
    }

    #[tokio::test]
    async fn test_note_vault_new_with_nonexistent_path() {
        let nonexistent_path = "/this/path/does/not/exist";
        let result = NoteVault::new(VaultConfig::new(nonexistent_path)).await;

        assert!(result.is_err());
        match result.unwrap_err() {
            VaultError::VaultPathNotFound { path } => {
                assert_eq!(path, nonexistent_path);
            }
            _ => panic!("Expected VaultPathNotFound error"),
        }
    }

    #[tokio::test]
    async fn test_note_vault_new_with_file_instead_of_directory() {
        // Create a temporary file
        let temp_file = tempfile::NamedTempFile::new().unwrap();
        let file_path = temp_file.path();

        let result = NoteVault::new(VaultConfig::new(file_path)).await;

        assert!(result.is_err());
        match result.unwrap_err() {
            VaultError::FSError(FSError::InvalidPath { message, .. }) => {
                assert_eq!(message, "Path provided is not a directory");
            }
            _ => panic!("Expected FSError::InvalidPath"),
        }
    }

    #[tokio::test]
    async fn test_note_vault_new_with_valid_directory() {
        let temp_dir = TempDir::new().unwrap();
        let dir_path = temp_dir.path();

        let result = NoteVault::new(VaultConfig::new(dir_path)).await;

        assert!(result.is_ok());
        let vault = result.unwrap();
        assert_eq!(vault.workspace_path(), dir_path);
        assert_eq!(vault.journal_path, VaultPath::new(DEFAULT_JOURNAL_PATH));
    }

    #[tokio::test]
    async fn test_get_todays_journal() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        let (title, note_path) = vault.get_todays_journal();

        // Check that title matches today's date format
        let today = Utc::now();
        let expected_title = today.format("%Y-%m-%d").to_string();
        assert_eq!(title, expected_title);

        // Check that the path is correct
        let expected_path = vault
            .journal_path
            .append(&VaultPath::note_path_from(&expected_title))
            .absolute();
        assert_eq!(note_path, expected_path);
    }

    #[tokio::test]
    async fn journal_entry_reports_creation_then_reuse() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        let (_, _, created_first) = vault.journal_entry().await.unwrap();
        assert!(created_first, "first call must create today's entry");

        let (_, _, created_second) = vault.journal_entry().await.unwrap();
        assert!(!created_second, "second call must reuse the existing entry");
    }

    #[tokio::test]
    async fn load_or_create_note_reports_creation_then_reuse() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();
        let path = VaultPath::note_path_from("notes/fresh.md");

        let (_, created_first) = vault.load_or_create_note(&path, None).await.unwrap();
        assert!(created_first, "missing note must be created");

        let (_, created_second) = vault.load_or_create_note(&path, None).await.unwrap();
        assert!(
            !created_second,
            "existing note must be loaded, not recreated"
        );
    }

    #[tokio::test]
    async fn test_journal_date_with_valid_journal_note() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        // Create a journal note path
        let journal_note_path = vault
            .journal_path
            .append(&VaultPath::note_path_from("2023-12-25"))
            .absolute();

        let result = vault.journal_date(&journal_note_path);

        assert!(result.is_some());
        let date = result.unwrap();
        assert_eq!(date, NaiveDate::from_ymd_opt(2023, 12, 25).unwrap());
    }

    #[tokio::test]
    async fn test_journal_date_with_invalid_date_format() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        // Create a note path with invalid date format
        let invalid_journal_path = vault
            .journal_path
            .append(&VaultPath::note_path_from("invalid-date"))
            .absolute();

        let result = vault.journal_date(&invalid_journal_path);
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_journal_date_with_non_journal_path() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        // Create a note path outside of journal directory
        let non_journal_path = VaultPath::new("/other/2023-12-25.md");

        let result = vault.journal_date(&non_journal_path);
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_journal_date_with_non_note_path() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        // Create a directory path (not a note)
        let directory_path = vault.journal_path.append(&VaultPath::new("2023-12-25"));

        let result = vault.journal_date(&directory_path);
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_path_to_pathbuf() {
        let temp_dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
            .await
            .unwrap();

        let vault_path = VaultPath::new("/test/note.md");
        let result = vault.path_to_pathbuf(&vault_path);

        let expected = vault_path.to_pathbuf(&vault.workspace_path);
        assert_eq!(result, expected);
    }

    #[test]
    fn test_directory_details() {
        let path = VaultPath::new("/test/directory");
        let details = DirectoryDetails { path: path.clone() };

        assert_eq!(details.path, path);
    }

    #[test]
    fn test_search_result_note() {
        let path = VaultPath::new("/test/note.md");
        let content_data = NoteContentData::new("Test Note".to_string(), 12345);
        let result = SearchResult::note(&path, &content_data);

        assert_eq!(result.path, path);
        match result.rtype {
            ResultType::Note(data) => assert_eq!(data, content_data),
            _ => panic!("Expected Note result type"),
        }
    }

    #[test]
    fn test_search_result_directory() {
        let path = VaultPath::new("/test/directory");
        let result = SearchResult::directory(&path);

        assert_eq!(result.path, path);
        match result.rtype {
            ResultType::Directory => (),
            _ => panic!("Expected Directory result type"),
        }
    }

    #[test]
    fn test_search_result_attachment() {
        let path = VaultPath::new("/test/image.png");
        let result = SearchResult::attachment(&path);

        assert_eq!(result.path, path);
        match result.rtype {
            ResultType::Attachment => (),
            _ => panic!("Expected Attachment result type"),
        }
    }

    #[test]
    fn test_result_type_equality() {
        let content_data = NoteContentData::new("Test Note".to_string(), 12345);
        let note_type1 = ResultType::Note(content_data.clone());
        let note_type2 = ResultType::Note(content_data);
        let directory_type = ResultType::Directory;
        let attachment_type = ResultType::Attachment;

        assert_eq!(note_type1, note_type2);
        assert_eq!(directory_type, ResultType::Directory);
        assert_eq!(attachment_type, ResultType::Attachment);
        assert_ne!(directory_type, attachment_type);
    }

    #[test]
    fn test_vault_browse_options_builder_default() {
        let builder = VaultBrowseOptionsBuilder::default();

        // We can't directly inspect private fields, but we can test the build result
        let (options, _receiver) = builder.build();

        assert_eq!(options.path, VaultPath::root());
        assert_eq!(options.validation, NotesValidation::None);
        assert!(!options.recursive);
    }

    #[test]
    fn test_vault_browse_options_builder_new() {
        let test_path = VaultPath::new("/test/path");
        let builder = VaultBrowseOptionsBuilder::new(&test_path);

        let (options, _receiver) = builder.build();

        assert_eq!(options.path, test_path);
        assert_eq!(options.validation, NotesValidation::None);
        assert!(!options.recursive);
    }

    #[test]
    fn test_vault_browse_options_builder_path() {
        let initial_path = VaultPath::new("/initial");
        let new_path = VaultPath::new("/new/path");

        let builder = VaultBrowseOptionsBuilder::new(&initial_path).path(new_path.clone());

        let (options, _receiver) = builder.build();

        assert_eq!(options.path, new_path);
    }

    #[test]
    fn test_vault_browse_options_builder_recursive() {
        let path = VaultPath::new("/test");

        let builder = VaultBrowseOptionsBuilder::new(&path).recursive(true);
        let (options, _receiver) = builder.build();
        assert!(options.recursive);

        let builder = VaultBrowseOptionsBuilder::new(&path).recursive(false);
        let (options, _receiver) = builder.build();
        assert!(!options.recursive);
    }

    #[test]
    fn test_vault_browse_options_builder_validation_modes() {
        let path = VaultPath::new("/test");

        for v in [
            NotesValidation::Full,
            NotesValidation::Fast,
            NotesValidation::None,
        ] {
            let builder = VaultBrowseOptionsBuilder::new(&path).validation(v);
            let (options, _receiver) = builder.build();
            assert_eq!(options.validation, v);
        }
    }

    #[test]
    fn test_vault_browse_options_builder_chaining() {
        let path = VaultPath::new("/test");
        let new_path = VaultPath::new("/new");

        let builder = VaultBrowseOptionsBuilder::new(&path)
            .path(new_path.clone())
            .recursive(true)
            .validation(NotesValidation::Full);

        let (options, _receiver) = builder.build();

        assert_eq!(options.path, new_path);
        assert!(options.recursive);
        assert_eq!(options.validation, NotesValidation::Full);
    }

    #[test]
    fn test_vault_browse_options_build_returns_channel() {
        let path = VaultPath::new("/test");
        let builder = VaultBrowseOptionsBuilder::new(&path);

        let (_options, receiver) = builder.build();

        // Test that the receiver is valid by checking if it's ready to receive
        // (it should be empty initially)
        assert!(receiver.try_recv().is_err());
    }

    #[test]
    fn test_notes_validation_display() {
        assert_eq!(format!("{}", NotesValidation::Full), "Full");
        assert_eq!(format!("{}", NotesValidation::Fast), "Fast");
        assert_eq!(format!("{}", NotesValidation::None), "None");
    }

    #[test]
    fn test_vault_browse_options_display() {
        let path = VaultPath::new("/test/path");
        let builder = VaultBrowseOptionsBuilder::new(&path)
            .recursive(true)
            .validation(NotesValidation::Full);

        let (options, _receiver) = builder.build();
        let display_string = format!("{}", options);

        assert!(display_string.contains("Path: `/test/path`"));
        assert!(display_string.contains("Validation Type: `Full`"));
        assert!(display_string.contains("Recursive: `true`"));
    }

    #[test]
    fn test_default_journal_path_constant() {
        assert_eq!(DEFAULT_JOURNAL_PATH, "/journal");
    }

    // Verifies that validate_and_init rejects a vault containing case-insensitive
    // path conflicts (e.g. note.md vs Note.md, projects/ vs Projects/).
    // Linux only: macOS and Windows filesystems are case-insensitive by default,
    // so creating note.md + Note.md would silently overwrite rather than produce two files.
    #[cfg(target_os = "linux")]
    #[tokio::test]
    async fn rejects_vault_with_case_conflicts() {
        let tmp = TempDir::new().unwrap();
        // file conflict at root
        std::fs::write(tmp.path().join("note.md"), "lowercase").unwrap();
        std::fs::write(tmp.path().join("Note.md"), "uppercase").unwrap();
        // directory conflict at root
        std::fs::create_dir(tmp.path().join("projects")).unwrap();
        std::fs::create_dir(tmp.path().join("Projects")).unwrap();

        let vault = NoteVault::new(VaultConfig::new(tmp.path())).await.unwrap();
        let result = vault.validate_and_init().await;

        match result {
            Err(VaultError::CaseConflict { conflicts }) => {
                assert_eq!(
                    conflicts.len(),
                    2,
                    "expected 2 conflicts, got: {:?}",
                    conflicts
                );
                let joined = conflicts.join("\n");
                assert!(
                    joined.contains("note.md") && joined.contains("Note.md"),
                    "expected note.md conflict in list, got: {}",
                    joined
                );
                assert!(
                    joined.contains("projects") && joined.contains("Projects"),
                    "expected projects conflict in list, got: {}",
                    joined
                );
            }
            other => panic!(
                "expected CaseConflict, got: {}",
                match other {
                    Ok(_) => "Ok(_)".to_string(),
                    Err(e) => format!("Err({})", e),
                }
            ),
        }
    }

    #[tokio::test]
    async fn quick_note_creates_timestamped_note_in_inbox() {
        let dir = tempfile::TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let details = vault.quick_note("my quick thought").await.unwrap();
        let (parent, _) = details.path.get_parent_path();
        assert!(parent.to_string().contains("inbox"));

        let text = vault.get_note_text(&details.path).await.unwrap();
        assert_eq!(text, "my quick thought");
    }

    #[tokio::test]
    async fn quick_note_resolves_conflicts() {
        let dir = tempfile::TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let d1 = vault.quick_note("first").await.unwrap();
        let d2 = vault.quick_note("second").await.unwrap();

        assert_ne!(d1.path, d2.path);
        assert_eq!(vault.get_note_text(&d1.path).await.unwrap(), "first");
        assert_eq!(vault.get_note_text(&d2.path).await.unwrap(), "second");
    }

    #[tokio::test]
    async fn quick_note_uses_custom_inbox_path() {
        let dir = tempfile::TempDir::new().unwrap();
        let mut vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();
        vault.set_inbox_path(VaultPath::new("/capture"));

        let details = vault.quick_note("test").await.unwrap();
        let (parent, _) = details.path.get_parent_path();
        assert!(parent.to_string().contains("capture"));
    }

    #[tokio::test]
    async fn create_note_errors_when_file_exists() {
        let dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let path = VaultPath::new("/already.md");
        vault.create_note(&path, "first").await.unwrap();

        match vault.create_note(&path, "second").await {
            Err(VaultError::NoteExists { path: p }) => assert_eq!(p, path.flatten()),
            other => panic!("expected NoteExists, got {:?}", other.err()),
        }

        // The original content must be intact (no overwrite).
        let text = vault.get_note_text(&path).await.unwrap();
        assert_eq!(text, "first");
    }

    #[tokio::test]
    async fn create_directory_errors_when_dir_exists() {
        let dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let path = VaultPath::new("/projects");
        vault.create_directory(&path).await.unwrap();

        match vault.create_directory(&path).await {
            Err(VaultError::DirectoryExists { path: p }) => assert_eq!(p, path),
            other => panic!("expected DirectoryExists, got {:?}", other.err()),
        }
    }

    #[tokio::test]
    async fn rename_note_errors_when_dest_exists() {
        let dir = TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let from = VaultPath::new("/source.md");
        let to = VaultPath::new("/dest.md");
        vault.create_note(&from, "src").await.unwrap();
        vault.create_note(&to, "dst").await.unwrap();

        match vault.rename_note(&from, &to).await {
            Err(VaultError::FSError(FSError::InvalidPath { message, .. })) => {
                assert_eq!(message, "Destination path already exists");
            }
            other => panic!("expected destination-exists error, got {:?}", other.err()),
        }

        // Both files unchanged.
        assert_eq!(vault.get_note_text(&from).await.unwrap(), "src");
        assert_eq!(vault.get_note_text(&to).await.unwrap(), "dst");
    }

    /// Indexing a multi-level directory tree should pick up notes at every depth
    /// in a single pass (recursive walk + single transaction).
    #[tokio::test(flavor = "multi_thread")]
    async fn validate_and_init_indexes_nested_tree() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();

        std::fs::create_dir_all(root.join("dir1/sub")).unwrap();
        std::fs::write(root.join("a.md"), "# A").unwrap();
        std::fs::write(root.join("dir1/b.md"), "# B").unwrap();
        std::fs::write(root.join("dir1/sub/c.md"), "# C").unwrap();

        let vault = NoteVault::new(VaultConfig::new(root)).await.unwrap();
        vault.validate_and_init().await.unwrap();

        let all = vault.get_all_notes().await.unwrap();
        let names: Vec<String> = all.iter().map(|(e, _)| e.path.to_string()).collect();

        assert_eq!(all.len(), 3, "expected 3 notes, got: {:?}", names);
        assert!(names.iter().any(|p| p.ends_with("/a.md")), "{:?}", names);
        assert!(
            names.iter().any(|p| p.ends_with("/dir1/b.md")),
            "{:?}",
            names
        );
        assert!(
            names.iter().any(|p| p.ends_with("/dir1/sub/c.md")),
            "{:?}",
            names
        );
    }
}

#[cfg(test)]
mod vault_config_tests {
    use super::VaultConfig;
    use std::path::PathBuf;

    #[test]
    fn new_sets_workspace_and_no_db_path() {
        let cfg = VaultConfig::new("/tmp/ws");
        assert_eq!(cfg.workspace_path, PathBuf::from("/tmp/ws"));
        assert!(cfg.db_path.is_none());
    }

    #[test]
    fn with_db_path_overrides_default() {
        let cfg = VaultConfig::new("/tmp/ws").with_db_path("/var/cache/foo.kimuncache");
        assert_eq!(
            cfg.db_path.as_deref(),
            Some(std::path::Path::new("/var/cache/foo.kimuncache"))
        );
    }

    #[tokio::test]
    async fn note_vault_new_uses_vault_config_with_legacy_default() {
        use crate::{NoteVault, VaultConfig};
        let tmp = tempfile::TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(tmp.path())).await.unwrap();
        let expected = tmp.path().join("kimun.sqlite");
        assert!(
            expected.exists(),
            "legacy DB path should be used when db_path is None"
        );
        drop(vault);
    }

    #[tokio::test]
    async fn note_vault_new_with_explicit_db_path_uses_override() {
        use crate::{NoteVault, VaultConfig};
        let workspace = tempfile::TempDir::new().unwrap();
        let cache_dir = tempfile::TempDir::new().unwrap();
        let custom_db = cache_dir.path().join("my-vault.kimuncache");
        let vault = NoteVault::new(VaultConfig::new(workspace.path()).with_db_path(&custom_db))
            .await
            .unwrap();
        assert!(custom_db.exists());
        assert!(!workspace.path().join("kimun.sqlite").exists());
        drop(vault);
    }
}

#[cfg(test)]
mod label_api_tests {
    use super::*;
    use crate::nfs::VaultPath;

    async fn new_vault() -> (tempfile::TempDir, NoteVault) {
        let tmp = tempfile::TempDir::new().unwrap();
        let cfg = VaultConfig::new(tmp.path().to_path_buf());
        let vault = NoteVault::new(cfg).await.unwrap();
        vault.validate_and_init().await.unwrap();
        (tmp, vault)
    }

    #[tokio::test]
    async fn list_labels_returns_distinct_lowercase_names() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/a.md"), "x #Foo and #bar")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/b.md"), "y #foo only")
            .await
            .unwrap();

        let mut labels = vault.list_labels().await.unwrap();
        labels.sort();
        assert_eq!(labels, vec!["bar".to_string(), "foo".to_string()]);
    }

    #[tokio::test]
    async fn notes_with_label_is_case_insensitive() {
        let (_tmp, vault) = new_vault().await;
        let a = VaultPath::note_path_from("/a.md");
        let b = VaultPath::note_path_from("/b.md");
        vault.create_note(&a, "x #Important").await.unwrap();
        vault.create_note(&b, "x #important #other").await.unwrap();

        let mut paths = vault.notes_with_label("IMPORTANT").await.unwrap();
        paths.sort_by_key(|p| p.to_string());
        assert_eq!(paths, vec![a, b]);
    }

    #[tokio::test]
    async fn notes_with_unknown_label_returns_empty() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/a.md"), "x")
            .await
            .unwrap();
        let paths = vault.notes_with_label("nosuch").await.unwrap();
        assert!(paths.is_empty());
    }

    #[tokio::test]
    async fn label_counts_returns_count_per_label() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/a.md"), "x #foo #bar")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/b.md"), "y #foo")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/c.md"), "z #baz")
            .await
            .unwrap();

        let counts = vault.label_counts().await.unwrap();
        assert_eq!(
            counts,
            vec![
                ("bar".to_string(), 1usize),
                ("baz".to_string(), 1usize),
                ("foo".to_string(), 2usize),
            ],
        );
    }

    #[tokio::test]
    async fn label_counts_empty_vault_returns_empty() {
        let (_tmp, vault) = new_vault().await;
        let counts = vault.label_counts().await.unwrap();
        assert!(counts.is_empty());
    }
}

#[cfg(test)]
mod suggest_api_tests {
    use super::*;
    use crate::nfs::VaultPath;

    async fn new_vault() -> (tempfile::TempDir, NoteVault) {
        let tmp = tempfile::TempDir::new().unwrap();
        let cfg = VaultConfig::new(tmp.path().to_path_buf());
        let vault = NoteVault::new(cfg).await.unwrap();
        vault.validate_and_init().await.unwrap();
        (tmp, vault)
    }

    // Note: vault paths are stored lowercased (see VaultPathSlice::new), so
    // these tests assert on the lowercase form. The `name` field strips the
    // note extension via VaultPath::get_clean_name (no `.md` suffix).

    #[tokio::test]
    async fn suggest_notes_empty_prefix_returns_top_n() {
        let (_tmp, vault) = new_vault().await;
        for name in ["Alpha", "Beta", "Gamma"] {
            vault
                .create_note(&VaultPath::note_path_from(format!("/{name}.md")), "body")
                .await
                .unwrap();
        }

        let mut got = vault.suggest_notes_by_prefix("", 50).await.unwrap();
        got.sort_by(|a, b| a.name.cmp(&b.name));
        let names: Vec<String> = got.into_iter().map(|s| s.name).collect();
        assert_eq!(names, vec!["alpha", "beta", "gamma"]);
    }

    #[tokio::test]
    async fn suggest_notes_prefix_is_case_insensitive() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/Meeting.md"), "x")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/melon.md"), "x")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/zebra.md"), "x")
            .await
            .unwrap();

        let got = vault.suggest_notes_by_prefix("ME", 50).await.unwrap();
        let names: std::collections::HashSet<String> = got.into_iter().map(|s| s.name).collect();
        assert!(names.contains("meeting"));
        assert!(names.contains("melon"));
        assert!(!names.contains("zebra"));
    }

    #[tokio::test]
    async fn suggest_notes_respects_limit() {
        let (_tmp, vault) = new_vault().await;
        for i in 0..10 {
            vault
                .create_note(&VaultPath::note_path_from(format!("/note{i}.md")), "x")
                .await
                .unwrap();
        }
        let got = vault.suggest_notes_by_prefix("note", 3).await.unwrap();
        assert_eq!(got.len(), 3);
    }

    #[tokio::test]
    async fn suggest_notes_keeps_same_name_at_different_paths_separate() {
        let (_tmp, vault) = new_vault().await;
        vault.create_directory(&VaultPath::new("/a")).await.unwrap();
        vault.create_directory(&VaultPath::new("/b")).await.unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/a/Shared.md"), "x")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/b/Shared.md"), "y")
            .await
            .unwrap();

        let got = vault.suggest_notes_by_prefix("Shared", 50).await.unwrap();
        assert_eq!(got.len(), 2, "duplicates by name must not be deduped");
        let mut paths: Vec<String> = got.iter().map(|s| s.path.to_string()).collect();
        paths.sort();
        assert!(paths[0].contains("/a/"));
        assert!(paths[1].contains("/b/"));
        assert!(got.iter().all(|s| s.name == "shared"));
    }

    #[tokio::test]
    async fn suggest_notes_empty_vault_returns_empty() {
        let (_tmp, vault) = new_vault().await;
        let got = vault.suggest_notes_by_prefix("anything", 50).await.unwrap();
        assert!(got.is_empty());
    }

    #[tokio::test]
    async fn suggest_notes_unicode_and_long_prefix_do_not_panic() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/over.md"), "x")
            .await
            .unwrap();
        let long = "a".repeat(4096);
        let _ = vault.suggest_notes_by_prefix(&long, 50).await.unwrap();
        let _ = vault.suggest_notes_by_prefix("Über", 50).await.unwrap();
    }

    #[tokio::test]
    async fn suggest_notes_special_like_chars_in_prefix_are_escaped() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/normal.md"), "x")
            .await
            .unwrap();
        // `%` and `_` are LIKE wildcards — escaping must prevent them matching
        // unrelated notes.
        let got = vault.suggest_notes_by_prefix("%", 50).await.unwrap();
        assert!(got.is_empty());
        let got = vault.suggest_notes_by_prefix("_", 50).await.unwrap();
        assert!(got.is_empty());
    }

    #[tokio::test]
    async fn suggest_tags_ranks_by_usage_count_then_name() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/a.md"), "x #foo #bar")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/b.md"), "y #foo")
            .await
            .unwrap();
        vault
            .create_note(&VaultPath::note_path_from("/c.md"), "z #foo #baz")
            .await
            .unwrap();

        // Prefix "" returns everything, ordered by usage_count desc, name asc.
        let got = vault.suggest_tags_by_prefix("", 50).await.unwrap();
        assert_eq!(got[0].label, "foo");
        assert_eq!(got[0].usage_count, 3);
        // bar and baz both have count 1; alphabetical tie-break puts bar first.
        let labels: Vec<&str> = got.iter().map(|t| t.label.as_str()).collect();
        assert_eq!(labels, vec!["foo", "bar", "baz"]);
    }

    #[tokio::test]
    async fn suggest_tags_prefix_is_case_insensitive() {
        let (_tmp, vault) = new_vault().await;
        vault
            .create_note(&VaultPath::note_path_from("/a.md"), "x #Projects")
            .await
            .unwrap();
        let got = vault.suggest_tags_by_prefix("PRO", 50).await.unwrap();
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].label, "projects");
    }

    #[tokio::test]
    async fn suggest_tags_respects_limit() {
        let (_tmp, vault) = new_vault().await;
        for i in 0..5 {
            vault
                .create_note(
                    &VaultPath::note_path_from(format!("/n{i}.md")),
                    format!("x #tag{i}"),
                )
                .await
                .unwrap();
        }
        let got = vault.suggest_tags_by_prefix("tag", 2).await.unwrap();
        assert_eq!(got.len(), 2);
    }

    #[tokio::test]
    async fn suggest_tags_empty_vault_returns_empty() {
        let (_tmp, vault) = new_vault().await;
        let got = vault.suggest_tags_by_prefix("", 50).await.unwrap();
        assert!(got.is_empty());
    }
}

#[cfg(test)]
mod modify_backup_tests {
    use super::{NoteVault, VaultConfig};
    use crate::error::VaultError;
    use crate::nfs::VaultPath;
    use std::path::{Path, PathBuf};

    async fn backup_vault() -> (tempfile::TempDir, NoteVault) {
        let temp = tempfile::TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp.path()).with_backup(true))
            .await
            .unwrap();
        vault.validate_and_init().await.unwrap();
        (temp, vault)
    }

    fn backups_dir_today(workspace: &Path) -> PathBuf {
        let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
        workspace.join(".kimun").join("backups").join(date)
    }

    // ---- replace_in_note ----

    #[tokio::test]
    async fn replace_swaps_unique_substring() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "hello world").await.unwrap();

        let n = vault
            .replace_in_note(&p, "world", "there", false, false)
            .await
            .unwrap();

        assert_eq!(n, 1);
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello there");
    }

    #[tokio::test]
    async fn replace_errors_when_absent() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "hello").await.unwrap();

        let e = vault
            .replace_in_note(&p, "nope", "x", false, false)
            .await
            .unwrap_err();

        assert!(matches!(e, VaultError::ReplaceTextNotFound { .. }));
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello");
    }

    #[tokio::test]
    async fn replace_errors_when_not_unique() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a a a").await.unwrap();

        let e = vault
            .replace_in_note(&p, "a", "b", false, false)
            .await
            .unwrap_err();

        assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "a a a");
    }

    #[tokio::test]
    async fn replace_all_replaces_every_occurrence() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a a a").await.unwrap();

        let n = vault
            .replace_in_note(&p, "a", "b", true, false)
            .await
            .unwrap();

        assert_eq!(n, 3);
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "b b b");
    }

    #[tokio::test]
    async fn replace_regex_unique_swaps_match() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "version 1.2.3 here").await.unwrap();

        let n = vault
            .replace_in_note(&p, r"\d+\.\d+\.\d+", "9.9.9", false, true)
            .await
            .unwrap();

        assert_eq!(n, 1);
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "version 9.9.9 here");
    }

    #[tokio::test]
    async fn replace_regex_all_with_capture_groups() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a1 b2 c3").await.unwrap();

        let n = vault
            .replace_in_note(&p, r"([a-z])(\d)", "$2$1", true, true)
            .await
            .unwrap();

        assert_eq!(n, 3);
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "1a 2b 3c");
    }

    #[tokio::test]
    async fn replace_regex_not_unique_errors() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "cat cot cut").await.unwrap();

        let e = vault
            .replace_in_note(&p, r"c.t", "X", false, true)
            .await
            .unwrap_err();

        assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "cat cot cut");
    }

    #[tokio::test]
    async fn replace_regex_invalid_pattern_errors_and_leaves_note() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "untouched").await.unwrap();

        let e = vault
            .replace_in_note(&p, "(unclosed", "y", false, true)
            .await
            .unwrap_err();

        assert!(matches!(e, VaultError::InvalidRegex { .. }));
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "untouched");
    }

    #[tokio::test]
    async fn replace_literal_treats_metachars_literally() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a.b and axb").await.unwrap();

        // Literal mode: "a.b" matches only the literal, not the regex "a<any>b".
        let n = vault
            .replace_in_note(&p, "a.b", "Z", false, false)
            .await
            .unwrap();

        assert_eq!(n, 1);
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "Z and axb");
    }

    #[tokio::test]
    async fn preview_replace_reports_result_without_writing() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "hello world").await.unwrap();

        let pv = vault
            .preview_replace(&p, "world", "there", false, false)
            .await
            .unwrap();
        assert_eq!(pv.count, 1);
        assert_eq!(pv.content, "hello there");

        // The note on disk is untouched.
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello world");
    }

    #[tokio::test]
    async fn preview_replace_surfaces_same_errors() {
        let (_t, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a a").await.unwrap();

        let e = vault
            .preview_replace(&p, "a", "b", false, false)
            .await
            .unwrap_err();
        assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "a a");
    }

    // ---- backups ----

    #[tokio::test]
    async fn overwrite_backs_up_previous_content_when_enabled() {
        let (temp, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "original").await.unwrap();

        vault.save_note(&p, "updated").await.unwrap();

        let backup = backups_dir_today(temp.path()).join("note.md");
        assert_eq!(std::fs::read_to_string(&backup).unwrap(), "original");
        assert_eq!(vault.get_note_text(&p).await.unwrap(), "updated");
    }

    #[tokio::test]
    async fn overwrite_does_not_back_up_when_disabled() {
        let temp = tempfile::TempDir::new().unwrap();
        let vault = NoteVault::new(VaultConfig::new(temp.path())).await.unwrap();
        vault.validate_and_init().await.unwrap();
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "original").await.unwrap();

        vault.save_note(&p, "updated").await.unwrap();

        assert!(!temp.path().join(".kimun").join("backups").exists());
    }

    #[tokio::test]
    async fn delete_backs_up_when_enabled() {
        let (temp, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "content").await.unwrap();

        vault.delete_note(&p).await.unwrap();

        let backup = backups_dir_today(temp.path()).join("note.md");
        assert_eq!(std::fs::read_to_string(&backup).unwrap(), "content");
    }

    #[tokio::test]
    async fn repeat_same_day_edit_keeps_every_backup() {
        let (temp, vault) = backup_vault().await;
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "v0").await.unwrap();
        vault.save_note(&p, "v1").await.unwrap();
        vault.save_note(&p, "v2").await.unwrap();

        let dir = backups_dir_today(temp.path());
        let count = std::fs::read_dir(&dir).unwrap().count();
        assert_eq!(count, 2, "both pre-images should be retained");
    }

    #[tokio::test]
    async fn purge_removes_backups_older_than_retention() {
        let (temp, vault) = backup_vault().await;
        let old = temp
            .path()
            .join(".kimun")
            .join("backups")
            .join("2000-01-01");
        std::fs::create_dir_all(&old).unwrap();
        std::fs::write(old.join("ancient.md"), "x").unwrap();

        // Any backup write triggers the lazy purge sweep.
        let p = VaultPath::new("note.md");
        vault.create_note(&p, "a").await.unwrap();
        vault.save_note(&p, "b").await.unwrap();

        assert!(!old.exists(), "stale date-dir should be purged");
        assert!(
            backups_dir_today(temp.path()).exists(),
            "today's backup is kept"
        );
    }

    #[tokio::test]
    async fn backups_are_not_indexed() {
        let (_temp, vault) = backup_vault().await;
        let p = VaultPath::note_path_from("/note.md");
        vault.create_note(&p, "live").await.unwrap();
        vault.save_note(&p, "changed").await.unwrap();

        // Re-scan the filesystem; the walker must skip the hidden .kimun dir.
        vault.validate_and_init().await.unwrap();
        let notes = vault.get_all_notes().await.unwrap();

        let paths: Vec<String> = notes.iter().map(|(e, _)| e.path.to_string()).collect();
        // The walker must skip the hidden `.kimun` backups dir: no indexed note
        // may point into it.
        assert!(
            paths
                .iter()
                .all(|p| !p.contains(".kimun") && !p.contains("backups")),
            "backup files must not be indexed: {paths:?}"
        );
        // The live note is still indexed.
        assert!(
            paths.iter().any(|p| p.contains("note")),
            "live note should be indexed: {paths:?}"
        );
    }

    #[tokio::test]
    async fn rename_backs_up_backlink_victims() {
        let (temp, vault) = backup_vault().await;
        let b = VaultPath::note_path_from("/b.md");
        let a = VaultPath::note_path_from("/a.md");
        vault.create_note(&b, "I am b").await.unwrap();
        vault
            .create_note(&a, "see [[b]] for details")
            .await
            .unwrap();

        vault
            .rename_note(&b, &VaultPath::note_path_from("/c.md"))
            .await
            .unwrap();

        // The rename rewrites a's link b -> c. a's pre-rewrite content (still
        // pointing at b) must be backed up, since the rewrite goes through nfs
        // directly rather than NoteVault::save_note.
        let backup = backups_dir_today(temp.path()).join("a.md");
        let content = std::fs::read_to_string(&backup)
            .unwrap_or_else(|e| panic!("victim backup a.md should exist: {e}"));
        assert!(
            content.contains("[[b]]"),
            "backup should hold the pre-rewrite content: {content:?}"
        );
        // And the live note was actually rewritten to point at c.
        assert!(vault.get_note_text(&a).await.unwrap().contains("[[c]]"));
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn concurrent_replaces_do_not_lose_updates() {
        let (_temp, vault) = backup_vault().await;
        let path = VaultPath::note_path_from("/note.md");
        let tokens = ["a", "b", "c", "d", "e", "f", "g", "h"];
        vault.create_note(&path, tokens.join(" ")).await.unwrap();

        // Fire every replace concurrently against the same note. Each does a
        // read-modify-write; without the per-note lock, racing reads would drop
        // most updates (last-writer-wins). Clones share the lock map.
        let mut handles = Vec::new();
        for t in tokens {
            let v = vault.clone();
            let p = path.clone();
            handles.push(tokio::spawn(async move {
                v.replace_in_note(&p, t, &t.to_uppercase(), false, false)
                    .await
                    .unwrap();
            }));
        }
        for h in handles {
            h.await.unwrap();
        }

        // All eight replacements survived; none lost to a racing writer.
        assert_eq!(vault.get_note_text(&path).await.unwrap(), "A B C D E F G H");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn concurrent_appends_do_not_lose_updates() {
        let (_temp, vault) = backup_vault().await;
        let path = VaultPath::note_path_from("/log.md");
        let lines = ["a", "b", "c", "d", "e", "f", "g", "h"];

        // append_to_note does its read-or-create + write under the per-note lock,
        // so concurrent appends to one note all land (none lost to a racing read).
        let mut handles = Vec::new();
        for l in lines {
            let v = vault.clone();
            let p = path.clone();
            handles.push(tokio::spawn(async move {
                v.append_to_note(&p, l, None).await.unwrap();
            }));
        }
        for h in handles {
            h.await.unwrap();
        }

        let text = vault.get_note_text(&path).await.unwrap();
        assert_eq!(text.lines().count(), lines.len(), "lines: {text:?}");
        for l in lines {
            assert!(text.contains(l), "missing {l} in {text:?}");
        }
    }
}

#[cfg(test)]
mod saved_search_tests {
    use super::*;
    use tempfile::TempDir;

    async fn make_vault(dir: &std::path::Path) -> NoteVault {
        NoteVault::new(VaultConfig::new(dir)).await.unwrap()
    }

    #[tokio::test]
    async fn saved_search_crud() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;

        assert!(vault.list_saved_searches().await.unwrap().is_empty());

        vault.save_search("todo", "#todo").await.unwrap();
        vault.save_search("links", ">{note}").await.unwrap();
        let all = vault.list_saved_searches().await.unwrap();
        assert_eq!(all.len(), 2);

        // Upsert by case-insensitive name: overwrites, no duplicate.
        vault.save_search("Todo", "#todo #urgent").await.unwrap();
        let all = vault.list_saved_searches().await.unwrap();
        assert_eq!(all.len(), 2);
        assert_eq!(
            all.iter()
                .find(|s| s.name.eq_ignore_ascii_case("todo"))
                .unwrap()
                .query,
            "#todo #urgent"
        );

        vault
            .rename_saved_search("links", "backlinks")
            .await
            .unwrap();
        assert!(vault
            .list_saved_searches()
            .await
            .unwrap()
            .iter()
            .any(|s| s.name == "backlinks"));

        vault.delete_saved_search("todo").await.unwrap();
        let all = vault.list_saved_searches().await.unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].name, "backlinks");
    }

    #[tokio::test]
    async fn suggest_by_prefix_filters_case_insensitively_and_caps() {
        let dir = TempDir::new().unwrap();
        let vault = make_vault(dir.path()).await;
        vault.save_search("Today", "#today").await.unwrap();
        vault.save_search("todo-week", "#todo").await.unwrap();
        vault.save_search("journal", "in:journal").await.unwrap();

        // Case-insensitive prefix match; "journal" excluded.
        let hits = vault
            .suggest_saved_searches_by_prefix("TO", 9)
            .await
            .unwrap();
        let names: Vec<&str> = hits.iter().map(|s| s.name.as_str()).collect();
        assert_eq!(names, vec!["Today", "todo-week"]);

        // Empty prefix lists all (in stored order).
        assert_eq!(
            vault
                .suggest_saved_searches_by_prefix("", 9)
                .await
                .unwrap()
                .len(),
            3
        );

        // Limit caps the result.
        assert_eq!(
            vault
                .suggest_saved_searches_by_prefix("", 2)
                .await
                .unwrap()
                .len(),
            2
        );

        // No match → empty.
        assert!(vault
            .suggest_saved_searches_by_prefix("zzz", 9)
            .await
            .unwrap()
            .is_empty());
    }
}
