use super::Message;
use crate::session::opencode::{self, OpencodeSession};
use crate::session::resolve_parent_session;
use crate::session::{self, SessionProvider, SessionSource};
use regex::RegexBuilder;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read};
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::Condvar;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;

static DESKTOP_TITLE_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Option<String>>>> =
    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
static CODEX_PROJECT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Option<String>>>> =
    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
// Render calls `extract_project_from_path` per visible group every frame;
// each Opencode resolution opens the SQLite DB twice without this cache.
// We only cache successful resolutions — a `None` from a transient failure
// (DB momentarily locked during startup, in-flight schema commit) would
// otherwise pin the "Opencode" fallback label for the rest of the process
// lifetime. Unresolvable sessions pay a re-resolution cost per frame, which
// is the right trade-off here.
static OPENCODE_PROJECT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, String>>> =
    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));

// Shared sentinel for cooperative cancellation across the ripgrep and
// Opencode search paths. Comparing on this constant avoids a silent
// failure mode where renaming the literal on one side turns cancellation
// into a "successful empty result" on the other.
pub(crate) const CANCELLED_ERR: &str = "cancelled";

/// A match from ripgrep search
#[derive(Debug, Clone)]
pub struct RipgrepMatch {
    pub file_path: String,
    pub message: Option<Message>,
    pub source: SessionSource,
}

/// Result of a search operation.
#[derive(Debug)]
#[non_exhaustive]
pub struct SearchResult {
    /// The matches found by the search.
    pub matches: Vec<RipgrepMatch>,
    /// Whether any file hit the per-file match limit, meaning results may be incomplete.
    pub truncated: bool,
}

fn build_regex_matcher(query: &str, use_regex: bool) -> Result<Option<regex::Regex>, String> {
    if use_regex {
        RegexBuilder::new(query)
            .case_insensitive(true)
            .build()
            .map(Some)
            .map_err(|e| format!("Invalid regex '{query}': {e}"))
    } else {
        Ok(None)
    }
}

#[cfg(test)]
fn build_ripgrep_args(query: &str, search_path: &str, use_regex: bool) -> Vec<String> {
    build_ripgrep_args_with_extra(query, search_path, use_regex, &[])
}

fn build_ripgrep_args_with_extra(
    query: &str,
    search_path: &str,
    use_regex: bool,
    extra: &[String],
) -> Vec<String> {
    let mut args = vec![
        "--json".to_string(),
        "--glob".to_string(),
        "*.jsonl".to_string(),
        "--max-count".to_string(),
        MAX_COUNT_PER_FILE.to_string(),
        "--ignore-case".to_string(),
    ];

    if !use_regex {
        args.push("--fixed-strings".to_string());
    }

    args.extend_from_slice(extra);

    let prefilter_patterns = build_prefilter_patterns(query, use_regex);
    if prefilter_patterns.len() == 1 {
        args.push(query.to_string());
    } else {
        for pattern in prefilter_patterns {
            args.push("-e".to_string());
            args.push(pattern);
        }
    }
    args.push(search_path.to_string());
    args
}

fn build_prefilter_patterns(query: &str, use_regex: bool) -> Vec<String> {
    let mut patterns = vec![query.to_string()];

    if use_regex {
        if is_regex_slash_command_query(query) {
            push_command_tag_prefilters(&mut patterns);
        }
        return patterns;
    }

    let Some(command_head) = slash_command_search_head(query) else {
        return patterns;
    };

    if command_head.is_empty() {
        push_command_tag_prefilters(&mut patterns);
        return patterns;
    }

    push_unique(&mut patterns, &format!("<command-message>{command_head}"));
    push_unique(&mut patterns, &format!("<command-name>{command_head}"));
    patterns
}

fn is_regex_slash_command_query(query: &str) -> bool {
    query.starts_with('/') || query.starts_with("^/")
}

fn slash_command_search_head(query: &str) -> Option<&str> {
    let command = query.strip_prefix('/')?;
    Some(command.split_whitespace().next().unwrap_or(""))
}

fn push_command_tag_prefilters(patterns: &mut Vec<String>) {
    push_unique(patterns, "<command-message>");
    push_unique(patterns, "<command-name>");
}

fn push_unique(patterns: &mut Vec<String>, pattern: &str) {
    if !patterns.iter().any(|existing| existing == pattern) {
        patterns.push(pattern.to_string());
    }
}

/// Search for query in JSONL files using ripgrep (test helper, discards truncation flag)
#[cfg(test)]
pub fn search(query: &str, search_path: &str) -> Result<Vec<RipgrepMatch>, String> {
    search_with_options(query, search_path, false)
}

/// Search with explicit regex mode option (single path, test helper, discards truncation flag)
#[cfg(test)]
fn search_with_options(
    query: &str,
    search_path: &str,
    use_regex: bool,
) -> Result<Vec<RipgrepMatch>, String> {
    let cancel = Arc::new(AtomicBool::new(false));
    search_multiple_paths(query, &[search_path.to_string()], use_regex, &cancel)
        .map(|result| result.matches)
}

/// Search multiple paths with explicit regex mode option.
/// Returns a `SearchResult` with matches and a truncation flag.
///
/// `cancel` is a cooperative cancellation token: when set to `true` before invocation
/// the function returns `Err("cancelled")` immediately without spawning ripgrep; when
/// flipped mid-flight the spawned `rg` child is killed and the function returns
/// `Err("cancelled")` after the child is reaped.
pub fn search_multiple_paths(
    query: &str,
    search_paths: &[String],
    use_regex: bool,
    cancel: &Arc<AtomicBool>,
) -> Result<SearchResult, String> {
    search_multiple_paths_inner(query, search_paths, use_regex, cancel, &[])
}

/// Test-only variant that lets a test inject extra `rg` flags
/// (e.g. `--threads 1`) so timing-sensitive tests can be deterministic
/// without bloating the fixture. Production code paths always pass `&[]`.
#[cfg(test)]
fn search_multiple_paths_with_extra_args(
    query: &str,
    search_paths: &[String],
    use_regex: bool,
    cancel: &Arc<AtomicBool>,
    extra_args: &[String],
) -> Result<SearchResult, String> {
    search_multiple_paths_inner(query, search_paths, use_regex, cancel, extra_args)
}

fn search_multiple_paths_inner(
    query: &str,
    search_paths: &[String],
    use_regex: bool,
    cancel: &Arc<AtomicBool>,
    extra_args: &[String],
) -> Result<SearchResult, String> {
    if cancel.load(Ordering::Relaxed) {
        return Err(CANCELLED_ERR.into());
    }

    // Build the regex matcher once and reuse it across every path. Returns
    // a fast `Invalid regex` error before spawning ripgrep when `use_regex`
    // is true and `query` doesn't compile.
    let regex_matcher = build_regex_matcher(query, use_regex)?;

    let mut all_results = Vec::new();
    let mut any_truncated = false;

    for search_path in search_paths {
        if cancel.load(Ordering::Relaxed) {
            return Err(CANCELLED_ERR.into());
        }

        if search_path.is_empty() {
            continue;
        }

        if !std::path::Path::new(search_path).exists() {
            continue;
        }

        // Opencode storage roots are not JSONL-shaped — dispatch to a
        // separate scanner that streams matches from the SQLite database
        // via a single JOIN query (no per-hit metadata lookups).
        if is_opencode_storage_path(search_path) {
            let (results, truncated) =
                search_opencode_storage(query, search_path, regex_matcher.as_ref(), cancel)?;
            all_results.extend(results);
            any_truncated |= truncated;
            continue;
        }

        let (results, truncated) = search_single_path(
            query,
            search_path,
            use_regex,
            regex_matcher.as_ref(),
            cancel,
            extra_args,
        )?;
        all_results.extend(results);
        any_truncated |= truncated;
    }

    Ok(SearchResult {
        matches: all_results,
        truncated: any_truncated,
    })
}

const MAX_COUNT_PER_FILE: usize = 1000;

/// Per-session cap inside an Opencode DB so one chatty session can't fill
/// the entire `MAX_COUNT_PER_FILE` budget and hide every other session.
///
/// 100 is a tenth of `MAX_COUNT_PER_FILE` (1000) — large enough that a typical
/// session never bumps into it (most sessions yield ≤20 matches per query in
/// practice), small enough that a runaway agent loop can't crowd out 9+ other
/// sessions before the global cap fires.
const OPENCODE_PER_SESSION_CAP: usize = 100;

/// Maximum bytes of `rg` stderr we keep in memory for the failure-message
/// diagnostic. Beyond this we keep draining the pipe (so the child does not
/// block on a full pipe buffer) but discard the additional bytes. 64 KiB is
/// roughly the Linux pipe buffer size and far more than typical `rg`
/// warning output; truncated diagnostics are noted explicitly in the error.
const STDERR_KEEP_BYTES: usize = 64 * 1024;

/// Search a single path.
/// Returns (matches, truncated) where truncated is true if any file hit the per-file match limit.
///
/// Streams ripgrep stdout line-by-line so memory does not balloon on broad queries
/// (e.g. single-character searches against a multi-GB corpus). On each line the
/// `cancel` flag is polled; when set, the spawned child is killed, reaped, and the
/// function returns `Err("cancelled")`.
///
/// In addition to the per-line cancellation check, a dedicated watchdog thread polls
/// the `cancel` flag every 50 ms and kills the `rg` child as soon as the flag flips.
/// This guarantees responsive cancellation even when `rg` is busy scanning a huge
/// corpus and has not produced any stdout yet — without the watchdog, the main loop
/// would block in `read_line()` and never observe the flag. The watchdog uses a
/// `Condvar` for its 50 ms wait so the main thread can wake it instantly on
/// successful completion (no per-path latency tax).
fn search_single_path(
    query: &str,
    search_path: &str,
    use_regex: bool,
    regex_matcher: Option<&regex::Regex>,
    cancel: &Arc<AtomicBool>,
    extra_args: &[String],
) -> Result<(Vec<RipgrepMatch>, bool), String> {
    let args = build_ripgrep_args_with_extra(query, search_path, use_regex, extra_args);

    let mut child = Command::new("rg")
        .args(&args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("Failed to run ripgrep: {}", e))?;

    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| "ripgrep stdout was not captured".to_string())?;
    let reader = BufReader::new(stdout);

    // Drain stderr on a dedicated thread so a chatty `rg` (warnings about
    // unreadable files, regex diagnostics, etc.) cannot fill the pipe buffer
    // (~64 KiB on Linux) and block the child while we are stuck waiting for
    // stdout / `child.wait()`. We cap the *retained* bytes at
    // `STDERR_KEEP_BYTES` (~64 KiB) — beyond that we keep reading (and
    // discarding) so the pipe never fills, but stop appending. The
    // captured prefix is surfaced in any failure message, restoring the
    // diagnostics that the previous `Command::output()` path included.
    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| "ripgrep stderr was not captured".to_string())?;
    let stderr_handle = thread::spawn(move || {
        let mut keep: Vec<u8> = Vec::new();
        let mut buf = [0u8; 8 * 1024];
        let mut reader = BufReader::new(stderr);
        let mut truncated = false;
        // First, fill `keep` up to the cap.
        while keep.len() < STDERR_KEEP_BYTES {
            let remaining = STDERR_KEEP_BYTES - keep.len();
            let buf_len = buf.len().min(remaining);
            match reader.read(&mut buf[..buf_len]) {
                Ok(0) => return (keep, false),
                Ok(n) => keep.extend_from_slice(&buf[..n]),
                Err(_) => return (keep, false),
            }
        }
        // Then, drain (and discard) anything else so the pipe never blocks.
        loop {
            match reader.read(&mut buf) {
                Ok(0) => break,
                Ok(_) => truncated = true,
                Err(_) => break,
            }
        }
        (keep, truncated)
    });

    // Wrap the child in `Arc<Mutex<Option<Child>>>` so the watchdog thread
    // can call `kill()` on it from outside the blocking read loop. Without
    // this, `BufReader::lines()` parks in `read_line()` for the entire
    // ripgrep scan when the corpus is huge but produces no early matches —
    // the per-line cancel check never fires because no line is ever yielded.
    let child = Arc::new(Mutex::new(Some(child)));

    // Watchdog completion signal — separate from `cancel` so the watchdog
    // can exit cleanly after a normal search completes (no thread leak).
    // `(Mutex<bool>, Condvar)` lets the main thread wake the watchdog
    // immediately on completion via `notify_one`, instead of leaving it
    // parked inside `thread::sleep(50ms)`. Without the wakeable wait every
    // uncancelled search would stall up to 50 ms per path on `join()`.
    let done = Arc::new((Mutex::new(false), Condvar::new()));

    let watchdog_cancel = cancel.clone();
    let watchdog_done = done.clone();
    let watchdog_child = child.clone();
    let watchdog_handle = thread::spawn(move || {
        let (lock, cvar) = &*watchdog_done;
        let mut done_guard = lock.lock().unwrap();
        while !*done_guard {
            if watchdog_cancel.load(Ordering::Relaxed) {
                if let Ok(mut guard) = watchdog_child.lock() {
                    if let Some(c) = guard.as_mut() {
                        let _ = c.kill();
                    }
                }
                return;
            }
            let (new_guard, _timeout) = cvar
                .wait_timeout(done_guard, Duration::from_millis(50))
                .unwrap();
            done_guard = new_guard;
        }
    });

    let mut results = Vec::new();
    let mut truncated = false;
    let mut file_match_counts: std::collections::HashMap<String, usize> =
        std::collections::HashMap::new();

    let query_lower = query.to_lowercase();
    let mut resolve_cache: std::collections::HashMap<String, (String, String)> =
        std::collections::HashMap::new();
    let mut codex_subagent_cache: std::collections::HashMap<String, bool> =
        std::collections::HashMap::new();

    // Helper: take the child out of the mutex and reap it. Returns the
    // `Child` so the caller can call `wait()` on it directly without
    // holding the mutex (avoiding any contention with the watchdog).
    fn take_child(child: &Arc<Mutex<Option<Child>>>) -> Option<Child> {
        child.lock().ok().and_then(|mut g| g.take())
    }

    // Helper: signal the watchdog to exit and wake it from any
    // `wait_timeout` so it does not stall the join in `thread::sleep`.
    let signal_done = |done: &Arc<(Mutex<bool>, Condvar)>| {
        let (lock, cvar) = &**done;
        if let Ok(mut guard) = lock.lock() {
            *guard = true;
            cvar.notify_one();
        }
    };

    for line in reader.lines().map_while(Result::ok) {
        if cancel.load(Ordering::Relaxed) {
            // Cooperative cancel: kill+reap the child ourselves. The
            // watchdog will also see the flag and try to kill, but
            // `Child::kill()` on an already-dead process returns
            // `InvalidInput` which we ignore — the operation is idempotent
            // for our purposes.
            signal_done(&done);
            if let Some(mut c) = take_child(&child) {
                let _ = c.kill();
                let _ = c.wait();
            }
            // Reap helper threads so we do not leak them or their pipes.
            let _ = watchdog_handle.join();
            let _ = stderr_handle.join();
            return Err(CANCELLED_ERR.into());
        }

        if let Some(mut m) = parse_ripgrep_json(&line) {
            // Track raw ripgrep matches per file to detect --max-count truncation.
            // Uses a HashMap so interleaved multi-threaded ripgrep output is handled
            // correctly. Count uses the original file path (before agent resolution)
            // because that is what ripgrep's --max-count applies to.
            let count = file_match_counts.entry(m.file_path.clone()).or_insert(0);
            *count += 1;
            if *count >= MAX_COUNT_PER_FILE {
                truncated = true;
            }

            // Resolve Claude agent/subagent files and Codex spawned-subagent
            // rollout files to their parent session.
            let needs_parent_resolution = if is_agent_or_subagent_path(&m.file_path) {
                true
            } else {
                *codex_subagent_cache
                    .entry(m.file_path.clone())
                    .or_insert_with(|| {
                        session::codex_parent_thread_id_from_file(Path::new(&m.file_path)).is_some()
                    })
            };
            if needs_parent_resolution {
                let Some(ref msg) = m.message else {
                    continue; // No message to resolve — skip
                };
                let (resolved_sid, resolved_path) = resolve_cache
                    .entry(m.file_path.clone())
                    .or_insert_with(|| resolve_parent_session(&msg.session_id, &m.file_path))
                    .clone();
                m.file_path = resolved_path;
                if let Some(msg) = m.message.as_mut() {
                    msg.session_id = resolved_sid;
                }
            }
            // Post-filter: only keep matches where the MESSAGE CONTENT actually contains the query
            // This filters out false positives where query matched file path or metadata
            if let Some(ref msg) = m.message {
                let matches = if let Some(re) = regex_matcher {
                    re.is_match(&msg.content)
                } else {
                    msg.content.to_lowercase().contains(&query_lower)
                };
                if matches {
                    results.push(m);
                }
            }
        }
    }

    // The for-loop exited either because rg's stdout reached EOF (normal
    // completion) or because the watchdog killed the child (cancel was
    // observed before the next line was emitted). Signal the watchdog to
    // exit and reap it. The Condvar notify wakes the watchdog out of any
    // pending `wait_timeout(50ms)` so the join is essentially instant —
    // critical for keeping per-path overhead low on multi-path searches.
    signal_done(&done);
    let _ = watchdog_handle.join();

    // If cancel was observed by the watchdog (and we missed it because the
    // read loop was parked in `read_line`), surface the same Err("cancelled")
    // contract instead of treating the killed child's exit status as a
    // search failure.
    if cancel.load(Ordering::Relaxed) {
        if let Some(mut c) = take_child(&child) {
            let _ = c.wait();
        }
        let _ = stderr_handle.join();
        return Err(CANCELLED_ERR.into());
    }

    let mut child = take_child(&child).ok_or_else(|| "ripgrep child was lost".to_string())?;
    let status = child
        .wait()
        .map_err(|e| format!("Failed to wait for ripgrep: {}", e))?;

    // Wait for the stderr drainer to finish; the child has exited so the
    // pipe is closed and `read_to_end` returns. Failures here only mean the
    // thread panicked, which is not actionable for the caller.
    let (stderr_bytes, stderr_truncated) = stderr_handle.join().unwrap_or_default();

    // ripgrep exit code 1 means "no matches" — that is success for our purposes.
    if !status.success() && status.code() != Some(1) {
        let stderr_text = String::from_utf8_lossy(&stderr_bytes);
        let trimmed = stderr_text.trim();
        let suffix = if stderr_truncated {
            " [stderr truncated]"
        } else {
            ""
        };
        if trimmed.is_empty() {
            return Err(format!(
                "ripgrep search failed: exit status {}{}",
                status, suffix
            ));
        }
        return Err(format!(
            "ripgrep search failed: exit status {}: {}{}",
            status, trimmed, suffix
        ));
    }

    Ok((results, truncated))
}

fn is_opencode_storage_path(path: &str) -> bool {
    let normalized: std::borrow::Cow<'_, str> = if path.contains('\\') {
        std::borrow::Cow::Owned(path.replace('\\', "/"))
    } else {
        std::borrow::Cow::Borrowed(path)
    };
    Path::new(normalized.as_ref())
        .file_name()
        .and_then(|n| n.to_str())
        .is_some_and(opencode::is_opencode_db_filename)
}

/// Search the Opencode SQLite database for the given query.
///
/// Backed by [`opencode::search_parts_streaming`], which runs a single JOIN
/// against `part`/`message`/`session` and yields one row per matching
/// `(session_id, message_id)` pair. We cap at `MAX_COUNT_PER_FILE` matches
/// **across the whole database** (the DB is treated as a single "file" for
/// dispatch purposes), unlike the ripgrep path which applies the cap per
/// JSONL file. Rows are streamed in `p.time_created DESC` order. To prevent
/// a single chatty session from starving every other session out of the
/// budget, a per-session sub-cap (`OPENCODE_PER_SESSION_CAP`) limits how
/// many rows any one session can claim — rows beyond that count are
/// dropped silently and the global truncation flag is set so the caller
/// can warn the user. The cancel token is honoured both by the streaming
/// layer (polled every ~64 rows) and by the early-exit check below.
///
/// Returns `(matches, truncated)`. `truncated` is `true` when the
/// `MAX_COUNT_PER_FILE` cap was hit and additional matches were left
/// unstreamed. On mid-stream SQL errors (corrupt/locked DB) we suppress
/// the error to keep the mixed-provider search flowing and return whatever
/// rows were collected before the error, rather than discarding them.
fn search_opencode_storage(
    query: &str,
    storage_path: &str,
    regex_matcher: Option<&regex::Regex>,
    cancel: &Arc<AtomicBool>,
) -> Result<(Vec<RipgrepMatch>, bool), String> {
    let db = PathBuf::from(storage_path);
    if !db.exists() {
        return Ok((Vec::new(), false));
    }

    if cancel.load(Ordering::Relaxed) {
        return Err(CANCELLED_ERR.into());
    }

    // Regex queries skip the SQL LIKE prefilter (a regex can't be expressed
    // as a single LIKE pattern, so the literal text would miss matches like
    // `hello\s+opencode`). Fixed-string queries keep the prefilter so SQLite
    // doesn't scan every row.
    let mode = match regex_matcher {
        Some(re) => opencode::SearchMode::Regex(re),
        None => opencode::SearchMode::Fixed,
    };

    let mut results: Vec<RipgrepMatch> = Vec::new();
    let mut per_session: HashMap<String, usize> = HashMap::new();
    let mut truncated = false;

    // Isolate Opencode DB failures so a corrupt, locked, or
    // schema-incompatible database doesn't poison the whole mixed-provider
    // search — Claude/Codex hits from other paths must still surface. Mirror
    // the tolerant pattern used by `recent::list_sessions_for_recent` and
    // `cli::collect_opencode_list_entries`. Cancellation is the one error we
    // must keep propagating so the search machinery can short-circuit.
    let outcome = opencode::search_parts_streaming(&db, query, mode, cancel, |row| {
        let count = per_session.entry(row.session_id.clone()).or_insert(0);
        if *count >= OPENCODE_PER_SESSION_CAP {
            truncated = true;
            return ControlFlow::Continue(());
        }
        *count += 1;

        let role_str = match row.role {
            crate::session::record::MessageRole::User => "user".to_string(),
            crate::session::record::MessageRole::Assistant => "assistant".to_string(),
        };

        results.push(RipgrepMatch {
            file_path: row.session_file.to_string_lossy().to_string(),
            message: Some(Message {
                session_id: row.session_id,
                role: role_str,
                content: row.text.clone(),
                text_content: row.text,
                timestamp: row.timestamp,
                branch: None,
                file_path: Some(row.session_file.to_string_lossy().to_string()),
                line_number: 1,
                uuid: Some(row.message_id),
                parent_uuid: None,
            }),
            source: SessionSource::CLI,
        });

        if results.len() >= MAX_COUNT_PER_FILE {
            truncated = true;
            ControlFlow::Break(())
        } else {
            ControlFlow::Continue(())
        }
    });

    match outcome {
        Ok(()) => Ok((results, truncated)),
        Err(e) if e == CANCELLED_ERR => Err(e),
        // Preserve whatever rows were collected before the error rather than
        // discarding them: a mid-stream SQL failure (locked DB, decode error)
        // can fire after hundreds of valid matches have already been pushed.
        Err(e) => {
            crate::ccs_debug!(
                "opencode search dropped after partial results ({storage_path}): {e}"
            );
            Ok((results, truncated))
        }
    }
}

/// Check if a file path belongs to an agent or subagent session.
/// Returns true for paths containing `/subagents/` or filenames starting with `agent-`.
fn is_agent_or_subagent_path(path: &str) -> bool {
    if path.contains("/subagents/") {
        return true;
    }
    if let Some(filename) = path.rsplit('/').next() {
        if filename.starts_with("agent-") {
            return true;
        }
    }
    false
}

/// Parse ripgrep JSON output into RipgrepMatch
pub fn parse_ripgrep_json(json: &str) -> Option<RipgrepMatch> {
    let parsed: serde_json::Value = serde_json::from_str(json).ok()?;

    let msg_type = parsed.get("type")?.as_str()?;
    if msg_type != "match" {
        return None;
    }

    let data = parsed.get("data")?;
    let file_path = data.get("path")?.get("text")?.as_str()?.to_string();
    let line_text = data.get("lines")?.get("text")?.as_str()?;
    let line_number = data.get("line_number")?.as_u64()? as usize;

    let message = Message::from_jsonl_with_path(line_text.trim(), line_number, Some(&file_path));
    let source = SessionSource::from_path(&file_path);

    Some(RipgrepMatch {
        file_path,
        message,
        source,
    })
}

/// Decode an encoded directory name into a short readable project label.
/// Strips common home-dir prefixes and converts `--` to `/.` (hidden dirs), `-` to `/`.
/// Examples:
///   "-Users-user" -> "~"
///   "-Users-user--claude-skills-gist" -> "~/.claude/skills/gist"
///   "-private-tmp" -> "/private/tmp"
fn decode_dir_name_short(dir_name: &str) -> String {
    let stripped = dir_name.strip_prefix('-').unwrap_or(dir_name);
    let decoded = stripped
        .replace("--", "\x00")
        .replace('-', "/")
        .replace('\x00', "/.");
    let full_path = format!("/{}", decoded);

    // Try to shorten home directory prefix to ~
    if let Some(home) = dirs::home_dir() {
        if let Some(home_str) = home.to_str() {
            if let Some(rest) = full_path.strip_prefix(home_str) {
                if rest.is_empty() {
                    return "~".to_string();
                }
                return format!("~{}", rest);
            }
        }
    }

    full_path
}

/// Extract project/session name from file path
/// CLI format: /Users/user/.claude/projects/-Users-user-projects-myapp/session.jsonl
/// Desktop format: .../local-agent-mode-sessions/.../local_xxx/.claude/projects/-sessions-cool-name/xxx.jsonl
/// Desktop audit: .../local-agent-mode-sessions/.../local_xxx/audit.jsonl
pub fn extract_project_from_path(path: &str) -> String {
    if SessionProvider::from_path(path) == SessionProvider::Codex {
        if let Some(project) = read_codex_session_project(path) {
            return project;
        }
    }

    // Opencode synthetic path: `<db>#<session_id>`. Resolve through the DB
    // so the project label matches what `list_sessions_for_recent` / recent uses.
    if SessionProvider::from_path(path) == SessionProvider::Opencode {
        if let Ok(cache) = OPENCODE_PROJECT_CACHE.lock() {
            if let Some(hit) = cache.get(path) {
                return hit.clone();
            }
        }
        if let Some(resolved) = resolve_opencode_project_label(path) {
            if let Ok(mut cache) = OPENCODE_PROJECT_CACHE.lock() {
                cache.insert(path.to_string(), resolved.clone());
            }
            return resolved;
        }
        return "Opencode".to_string();
    }

    // Check for Desktop session name in path (e.g., -sessions-wizardly-vibrant-dirac)
    if let Some(sessions_idx) = path.find("-sessions-") {
        let after_sessions = &path[sessions_idx + 10..]; // Skip "-sessions-"
                                                         // Get the name (before the next /)
        let name = after_sessions.split('/').next().unwrap_or("");
        if !name.is_empty() {
            return name.to_string();
        }
    }

    // Check for CLI project name (e.g., -Users-user-projects-myapp)
    if let Some(projects_idx) = path.find("projects/") {
        let after_projects = &path[projects_idx + 9..]; // Skip "projects/"

        // Get the directory name (before the next /)
        let dir_name = after_projects.split('/').next().unwrap_or("");

        if !dir_name.is_empty() {
            // The project name is the last part after splitting by -projects-
            // e.g., "-Users-user-projects-myapp" -> "myapp"
            if let Some(last_projects_idx) = dir_name.rfind("-projects-") {
                return dir_name[last_projects_idx + 10..].to_string();
            }

            // No -projects- segment: extract last meaningful part from encoded dir name
            // e.g., "-Users-user" -> "~"
            // e.g., "-Users-user--claude-skills-gist" -> "~/.claude/skills/gist"
            // e.g., "-private-tmp" -> "/tmp"
            return decode_dir_name_short(dir_name);
        }
    }

    // Desktop audit.jsonl: try to read title from sibling JSON metadata file
    if path.contains("local-agent-mode-sessions") {
        if let Some(title) = read_desktop_session_title(path) {
            return title;
        }

        // Fallback: extract from local_xxx part
        for part in path.split('/') {
            if part.starts_with("local_") {
                let session_id = part.trim_start_matches("local_");
                if session_id.len() > 8 {
                    return format!("Desktop:{}", &session_id[..8]);
                }
                return format!("Desktop:{}", session_id);
            }
        }
        return "Desktop".to_string();
    }

    // Fallback: return basename without extension
    path.rsplit('/')
        .next()
        .unwrap_or("")
        .trim_end_matches(".jsonl")
        .to_string()
}

fn resolve_opencode_project_label(path: &str) -> Option<String> {
    let (db, sid) = opencode::parse_session_path(path)?;
    let session = OpencodeSession::from_db(&db, &sid)?;
    if let Some(label) = opencode::read_project_label(&db, &session.project_id) {
        return Some(label);
    }
    session
        .directory
        .as_deref()
        .and_then(|d| Path::new(d).file_name().and_then(|n| n.to_str()))
        .map(str::to_string)
}

fn read_codex_session_project(path: &str) -> Option<String> {
    if let Ok(cache) = CODEX_PROJECT_CACHE.lock() {
        if let Some(cached) = cache.get(path) {
            return cached.clone();
        }
    }

    let project = session::read_codex_session_cwd(path).map(|cwd| project_label_from_cwd(&cwd));

    if let Ok(mut cache) = CODEX_PROJECT_CACHE.lock() {
        cache.insert(path.to_string(), project.clone());
    }

    project
}

fn project_label_from_cwd(cwd: &str) -> String {
    let path = std::path::Path::new(cwd);
    if let Some(home) = dirs::home_dir() {
        if path == home {
            return "~".to_string();
        }
    }

    path.file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .map(|name| name.to_string())
        .unwrap_or_else(|| cwd.to_string())
}

/// Read Desktop session title from the sibling JSON metadata file (cached).
/// Path: .../local_xxx/audit.jsonl -> .../local_xxx.json
fn read_desktop_session_title(audit_path: &str) -> Option<String> {
    if let Ok(cache) = DESKTOP_TITLE_CACHE.lock() {
        if let Some(cached) = cache.get(audit_path) {
            return cached.clone();
        }
    }

    let result = read_desktop_session_title_uncached(audit_path);

    if let Ok(mut cache) = DESKTOP_TITLE_CACHE.lock() {
        cache.insert(audit_path.to_string(), result.clone());
    }

    result
}

fn read_desktop_session_title_uncached(audit_path: &str) -> Option<String> {
    use std::path::Path;

    let path = Path::new(audit_path);

    let local_dir = path.parent()?;
    let local_dir_name = local_dir.file_name()?.to_str()?;

    let parent_of_local = local_dir.parent()?;
    let json_path = parent_of_local.join(format!("{}.json", local_dir_name));

    let content = std::fs::read_to_string(&json_path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;

    json.get("title")
        .and_then(|t| t.as_str())
        .map(|s| s.to_string())
}

/// Sanitize content by removing ANSI escape codes and control characters
/// This prevents terminal corruption when displaying content that contains escape sequences
pub fn sanitize_content(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    let mut chars = content.chars().peekable();

    while let Some(c) = chars.next() {
        // Skip ANSI escape sequences starting with ESC
        if c == '\x1b' {
            match chars.peek() {
                // CSI sequence: ESC [ ... (letter)
                Some(&'[') => {
                    chars.next(); // consume '['
                                  // Skip until we hit a letter (the terminator)
                                  // This handles colors, cursor movement, etc.
                    while let Some(&next) = chars.peek() {
                        chars.next();
                        if next.is_ascii_alphabetic() {
                            break;
                        }
                    }
                }
                // OSC sequence: ESC ] ... (BEL or ESC \)
                Some(&']') => {
                    chars.next(); // consume ']'
                                  // Skip until BEL (\x07) or ST (ESC \)
                    while let Some(next) = chars.next() {
                        if next == '\x07' {
                            break; // BEL terminator
                        }
                        if next == '\x1b' {
                            // Check for ST (ESC \)
                            if chars.peek() == Some(&'\\') {
                                chars.next();
                                break;
                            }
                        }
                    }
                }
                // DEC special: ESC ( or ESC ) followed by a char
                Some(&'(') | Some(&')') => {
                    chars.next(); // consume '(' or ')'
                    chars.next(); // consume the character set selector
                }
                // SS2/SS3: ESC N or ESC O
                Some(&'N') | Some(&'O') => {
                    chars.next();
                }
                // Other single-char escapes: ESC followed by one char
                Some(_) => {
                    chars.next();
                }
                None => {}
            }
            continue;
        }

        // Handle carriage return
        if c == '\r' {
            // If followed by \n, convert to just \n (CRLF -> LF)
            if chars.peek() == Some(&'\n') {
                // Let the next iteration handle the \n
                continue;
            }
            // Standalone \r - replace with space to prevent cursor jumping back
            result.push(' ');
            continue;
        }

        // Skip control characters except newline and tab
        if c.is_control() && c != '\n' && c != '\t' {
            continue;
        }

        result.push(c);
    }

    result
}

#[derive(Clone, Copy)]
struct FoldedCharSpan {
    folded_start: usize,
    folded_end: usize,
    original_start: usize,
    original_end: usize,
}

fn find_case_insensitive_span(content: &str, query: &str) -> Option<(usize, usize)> {
    if query.is_empty() {
        return Some((0, 0));
    }

    let query_lower = query.to_lowercase();
    let mut content_lower = String::new();
    let mut folded_spans = Vec::new();

    for (original_start, ch) in content.char_indices() {
        let original_end = original_start + ch.len_utf8();
        for folded_ch in ch.to_lowercase() {
            let folded_start = content_lower.len();
            content_lower.push(folded_ch);
            folded_spans.push(FoldedCharSpan {
                folded_start,
                folded_end: content_lower.len(),
                original_start,
                original_end,
            });
        }
    }

    let folded_start = content_lower.find(&query_lower)?;
    let folded_end = folded_start + query_lower.len();
    let original_start = folded_spans
        .iter()
        .find(|span| folded_start >= span.folded_start && folded_start < span.folded_end)?
        .original_start;
    let original_end = folded_spans
        .iter()
        .find(|span| {
            let folded_last_byte = folded_end.saturating_sub(1);
            folded_last_byte >= span.folded_start && folded_last_byte < span.folded_end
        })?
        .original_end;

    Some((original_start, original_end))
}

fn previous_char_boundary(content: &str, byte_pos: usize) -> usize {
    let mut pos = byte_pos.min(content.len());
    while !content.is_char_boundary(pos) {
        pos -= 1;
    }
    pos
}

fn next_char_boundary(content: &str, byte_pos: usize) -> usize {
    let mut pos = byte_pos.min(content.len());
    while pos < content.len() && !content.is_char_boundary(pos) {
        pos += 1;
    }
    pos
}

fn truncated_context(content: &str, context_chars: usize) -> String {
    let total_chars = content.chars().count();
    if total_chars > context_chars * 2 {
        let truncated: String = content.chars().take(context_chars * 2).collect();
        format!("{}...", truncated)
    } else {
        content.to_string()
    }
}

/// Extract context around a known byte span in content.
/// Uses character-safe slicing to handle UTF-8 properly.
pub fn extract_context_around_span(
    content: &str,
    match_start: usize,
    match_end: usize,
    context_chars: usize,
) -> String {
    let start = previous_char_boundary(content, match_start);
    let mut end = next_char_boundary(content, match_end);
    if end < start {
        end = start;
    }

    let match_start_char = content[..start].chars().count();
    let match_end_char = content[..end].chars().count();
    let total_chars = content.chars().count();

    let start_char = match_start_char.saturating_sub(context_chars);
    let end_char = (match_end_char + context_chars).min(total_chars);
    let result: String = content
        .chars()
        .skip(start_char)
        .take(end_char - start_char)
        .collect();

    let mut output = String::new();
    if start_char > 0 {
        output.push_str("...");
    }
    output.push_str(&result);
    if end_char < total_chars {
        output.push_str("...");
    }
    output
}

/// Extract context around query match in content.
/// Uses character-safe slicing to handle UTF-8 properly.
pub fn extract_context(content: &str, query: &str, context_chars: usize) -> String {
    if let Some((match_start, match_end)) = find_case_insensitive_span(content, query) {
        extract_context_around_span(content, match_start, match_end, context_chars)
    } else {
        truncated_context(content, context_chars)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    fn create_test_session(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
        let path = dir.path().join(name);
        let mut file = std::fs::File::create(&path).unwrap();
        file.write_all(content.as_bytes()).unwrap();
        path
    }

    #[test]
    fn test_build_ripgrep_args_fixed_string_search_is_case_insensitive() {
        let args = build_ripgrep_args("hello", "/tmp/search", false);
        assert!(args.contains(&"--ignore-case".to_string()));
        assert!(args.contains(&"--fixed-strings".to_string()));
        assert_eq!(args.last(), Some(&"/tmp/search".to_string()));
    }

    #[test]
    fn test_build_ripgrep_args_regex_search_is_case_insensitive() {
        let args = build_ripgrep_args("hello.*world", "/tmp/search", true);
        assert!(args.contains(&"--ignore-case".to_string()));
        assert!(!args.contains(&"--fixed-strings".to_string()));
    }

    #[test]
    fn test_search_multiple_paths_invalid_regex_returns_error() {
        let cancel = Arc::new(AtomicBool::new(false));
        let result = search_multiple_paths(
            "(",
            &["/path/that/does/not/exist".to_string()],
            true,
            &cancel,
        );
        assert!(result.is_err());
        let err = result.err().unwrap();
        assert!(err.contains("Invalid regex"), "unexpected error: {}", err);
    }

    #[test]
    fn test_search_finds_matches() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help you today?"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:01:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results =
            search("Hello", temp_dir.path().to_str().unwrap()).expect("Search should succeed");

        assert!(!results.is_empty(), "Should find matches");
        assert!(results.iter().any(|r| r.message.is_some()));
    }

    #[test]
    fn test_search_is_case_insensitive() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results =
            search("hello", temp_dir.path().to_str().unwrap()).expect("Search should succeed");

        assert!(!results.is_empty(), "Should match regardless of case");
    }

    #[test]
    fn test_search_returns_empty_for_no_matches() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results = search("nonexistent_query_xyz", temp_dir.path().to_str().unwrap())
            .expect("Search should succeed");

        assert!(results.is_empty(), "Should return empty for no matches");
    }

    #[test]
    fn test_search_filters_metadata_matches() {
        // Test that searching for "abc123" (which is in sessionId) does NOT return results
        // because the actual message content doesn't contain "abc123"
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        // Search for sessionId which appears in metadata but NOT in message content
        let results =
            search("abc123", temp_dir.path().to_str().unwrap()).expect("Search should succeed");

        assert!(
            results.is_empty(),
            "Should NOT match sessionId, only message content"
        );
    }

    #[test]
    fn test_search_matches_content_only() {
        let temp_dir = TempDir::new().unwrap();
        // Message content contains "warmup" but session ID contains "adb"
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Running warmup"}]},"sessionId":"adb-test-session","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        // Search for "adb" - should NOT match because it's only in sessionId
        let adb_results =
            search("adb", temp_dir.path().to_str().unwrap()).expect("Search should succeed");
        assert!(
            adb_results.is_empty(),
            "Should NOT match 'adb' in sessionId"
        );

        // Search for "warmup" - should match because it's in content
        let warmup_results =
            search("warmup", temp_dir.path().to_str().unwrap()).expect("Search should succeed");
        assert!(
            !warmup_results.is_empty(),
            "Should match 'warmup' in content"
        );
    }

    #[test]
    fn test_search_finds_command_message_by_rendered_slash_command() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"<command-message>revdiff:revdiff</command-message><command-args></command-args>"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results = search("/revdiff:revdiff", temp_dir.path().to_str().unwrap())
            .expect("Search should succeed");

        assert_eq!(results.len(), 1);
        assert_eq!(
            results[0].message.as_ref().unwrap().content,
            "/revdiff:revdiff"
        );
    }

    #[test]
    fn test_search_finds_command_name_args_by_rendered_slash_command() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"<command-name>foo</command-name><command-args>bar baz</command-args>"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results =
            search("/foo bar", temp_dir.path().to_str().unwrap()).expect("Search should succeed");

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].message.as_ref().unwrap().content, "/foo bar baz");
    }

    #[test]
    fn test_search_regex_finds_command_name_args_by_rendered_slash_command() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"<command-name>foo</command-name><command-args>bar baz</command-args>"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results = search_with_options("^/foo bar", temp_dir.path().to_str().unwrap(), true)
            .expect("Search should succeed");

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].message.as_ref().unwrap().content, "/foo bar baz");
    }

    #[test]
    fn test_search_command_prefilter_keeps_normalized_post_filter() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"<command-name>foo</command-name><command-args>different</command-args>"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;

        create_test_session(&temp_dir, "session.jsonl", session_content);

        let results =
            search("/foo bar", temp_dir.path().to_str().unwrap()).expect("Search should succeed");

        assert!(
            results.is_empty(),
            "raw command tag prefilter must not bypass normalized content filtering"
        );
    }

    #[test]
    fn test_parse_ripgrep_json_match() {
        let rg_json = r#"{"type":"match","data":{"path":{"text":"/path/to/session.jsonl"},"lines":{"text":"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Hello Claude\"}]},\"sessionId\":\"abc123\",\"timestamp\":\"2025-01-09T10:00:00Z\"}\n"},"line_number":1}}"#;

        let result = parse_ripgrep_json(rg_json);

        assert!(result.is_some(), "Should parse ripgrep match");
        let m = result.unwrap();
        assert_eq!(m.file_path, "/path/to/session.jsonl");
        assert!(m.message.is_some());
        assert_eq!(m.message.as_ref().unwrap().session_id, "abc123");
    }

    #[test]
    fn test_parse_ripgrep_json_skips_non_match() {
        let rg_json = r#"{"type":"begin","data":{"path":{"text":"/path/to/session.jsonl"}}}"#;

        let result = parse_ripgrep_json(rg_json);

        assert!(result.is_none(), "Should skip non-match types");
    }

    #[test]
    fn test_extract_project_from_path() {
        let path = "/Users/user/.claude/projects/-Users-user-projects-myapp/session.jsonl";

        let project = extract_project_from_path(path);

        assert_eq!(project, "myapp");
    }

    #[test]
    fn test_is_opencode_storage_path_basename_only() {
        assert!(is_opencode_storage_path("/home/u/opencode.db"));
        assert!(is_opencode_storage_path(
            "/home/u/.local/share/opencode/opencode.db"
        ));
        assert!(is_opencode_storage_path("/home/u/opencode-dev.db"));
        assert!(is_opencode_storage_path("/home/u/opencode-canary.db"));
        assert!(!is_opencode_storage_path("/home/u/opencode.db.backup"));
        assert!(!is_opencode_storage_path("/home/u/opencode.dbtest"));
        assert!(!is_opencode_storage_path("/home/u/my-opencode.db-archive"));
        assert!(!is_opencode_storage_path("/home/u/.claude/projects/foo"));
    }

    #[test]
    fn test_extract_project_from_path_caches_opencode_label() {
        use rusqlite::Connection;
        use tempfile::TempDir;

        let dir = TempDir::new().unwrap();
        let db = dir.path().join("opencode.db");
        let conn = Connection::open(&db).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT,
                name TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
                slug TEXT NOT NULL DEFAULT '', directory TEXT, title TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            INSERT INTO project VALUES ('p1', '/tmp/cache-proj', 'git', 'cache-proj', 1, 1);
            INSERT INTO session VALUES ('ses_cache_label', 'p1', 's', '/tmp/cache-proj', 't', 1, 1);
            "#,
        )
        .unwrap();
        drop(conn);

        let path = opencode::synthetic_session_path(&db, "ses_cache_label");
        let first = extract_project_from_path(&path);
        assert_eq!(first, "cache-proj");

        std::fs::remove_file(&db).unwrap();
        let second = extract_project_from_path(&path);
        assert_eq!(second, "cache-proj", "cache should serve repeated calls");
    }

    #[test]
    fn test_extract_project_from_path_with_dashes() {
        let path = "/Users/user/.claude/projects/-Users-user-projects-my-cool-app/session.jsonl";

        let project = extract_project_from_path(path);

        assert_eq!(project, "my-cool-app");
    }

    #[test]
    fn test_extract_project_from_desktop_session_name() {
        // Desktop nested path with session name
        let path = "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_xxx/.claude/projects/-sessions-wizardly-vibrant-dirac/session.jsonl";

        let project = extract_project_from_path(path);

        assert_eq!(project, "wizardly-vibrant-dirac");
    }

    #[test]
    fn test_extract_project_from_desktop_audit() {
        // Desktop audit.jsonl without session name
        let path = "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_40338476-098c-4b67-b9be-4b05f12c3800/audit.jsonl";

        let project = extract_project_from_path(path);

        assert_eq!(project, "Desktop:40338476");
    }

    #[test]
    fn test_extract_context_basic() {
        let content = "This is some text with the word hello in the middle of it";

        let context = extract_context(content, "hello", 10);

        assert!(context.contains("hello"), "Should contain the query");
        assert!(context.len() <= 50, "Should be reasonably short"); // includes "..." ellipsis
    }

    #[test]
    fn test_extract_context_at_start() {
        let content = "hello world this is a test";

        let context = extract_context(content, "hello", 10);

        assert!(context.starts_with("hello"), "Should start with match");
    }

    #[test]
    fn test_extract_context_case_insensitive() {
        let content = "This is HELLO world";

        let context = extract_context(content, "hello", 10);

        assert!(
            context.to_lowercase().contains("hello"),
            "Should find case-insensitive"
        );
    }

    #[test]
    fn test_extract_context_cyrillic() {
        let content = "Сделаю: 1. Preview режим 2. Индикатор compacted";

        let context = extract_context(content, "Preview", 5);

        assert!(
            context.contains("Preview"),
            "Should find match in Cyrillic text"
        );
        // Should not panic on UTF-8 boundaries
    }

    #[test]
    fn test_extract_context_cyrillic_truncate() {
        let content = "Привет мир это тестовая строка на русском языке";

        // Should truncate without panicking on UTF-8 boundaries
        let context = extract_context(content, "nonexistent", 10);

        assert!(!context.is_empty(), "Should return truncated content");
    }

    #[test]
    fn test_extract_context_lowercase_expansion_before_match() {
        let content = "\u{0130}\u{00e9}";

        let context = extract_context(content, "\u{00e9}", 10);

        assert_eq!(context, content);
    }

    #[test]
    fn test_extract_context_around_span_uses_actual_match_offset() {
        let filler = "between ".repeat(80);
        let content = format!("START_MARKER foobar {filler} MATCH_MARKER foo END_MARKER");
        let matched = RegexBuilder::new(r"\bfoo\b")
            .build()
            .unwrap()
            .find(&content)
            .unwrap();

        let context = extract_context_around_span(&content, matched.start(), matched.end(), 20);

        assert!(context.contains("MATCH_MARKER foo"));
        assert!(!context.contains("START_MARKER foobar"));
    }

    #[test]
    fn test_sanitize_content_plain_text() {
        let content = "Hello world";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello world");
    }

    #[test]
    fn test_sanitize_content_ansi_color() {
        // Red text: ESC[31m Hello ESC[0m
        let content = "\x1b[31mHello\x1b[0m world";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello world");
    }

    #[test]
    fn test_sanitize_content_ansi_cursor_movement() {
        // Cursor movement sequences like ESC[2J (clear screen), ESC[H (home)
        let content = "\x1b[2J\x1b[HHello world";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello world");
    }

    #[test]
    fn test_sanitize_content_control_chars() {
        // Control characters like backspace, bell
        let content = "Hello\x08\x07 world";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello world");
    }

    #[test]
    fn test_sanitize_content_preserves_newlines() {
        let content = "Hello\nworld\ttab";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello\nworld\ttab");
    }

    #[test]
    fn test_sanitize_content_complex_ansi() {
        // Multiple ANSI codes: bold, color, reset
        let content = "\x1b[1m\x1b[32mGreen Bold\x1b[0m Normal";
        let result = sanitize_content(content);
        assert_eq!(result, "Green Bold Normal");
    }

    #[test]
    fn test_sanitize_content_preserves_cyrillic() {
        let content = "\x1b[31mПривет\x1b[0m мир";
        let result = sanitize_content(content);
        assert_eq!(result, "Привет мир");
    }

    #[test]
    fn test_sanitize_content_ansi_256_color() {
        // 256 color: ESC[38;5;123m
        let content = "\x1b[38;5;123mColored\x1b[0m text";
        let result = sanitize_content(content);
        assert_eq!(result, "Colored text");
    }

    #[test]
    fn test_sanitize_content_carriage_return() {
        // Carriage return without newline overwrites text
        let content = "Hello\rWorld";
        let result = sanitize_content(content);
        // Should strip \r to prevent cursor jumping back
        assert_eq!(result, "Hello World");
    }

    #[test]
    fn test_sanitize_content_crlf_to_lf() {
        // Windows line endings should become Unix line endings
        let content = "Hello\r\nWorld";
        let result = sanitize_content(content);
        assert_eq!(result, "Hello\nWorld");
    }

    #[test]
    fn test_sanitize_content_multiple_carriage_returns() {
        // Multiple carriage returns in sequence - each becomes a space
        let content = "First\r\r\rSecond";
        let result = sanitize_content(content);
        assert_eq!(result, "First   Second");
    }

    #[test]
    fn test_sanitize_content_osc_sequence() {
        // Operating System Command: ESC ] ... BEL
        let content = "\x1b]0;Window Title\x07Normal text";
        let result = sanitize_content(content);
        assert_eq!(result, "Normal text");
    }

    #[test]
    fn test_sanitize_content_osc_st_terminator() {
        // OSC with ST terminator: ESC ] ... ESC \
        let content = "\x1b]0;Title\x1b\\Normal text";
        let result = sanitize_content(content);
        assert_eq!(result, "Normal text");
    }

    #[test]
    fn test_sanitize_content_cursor_position() {
        // Cursor position: ESC[row;colH
        let content = "\x1b[10;20HText at position";
        let result = sanitize_content(content);
        assert_eq!(result, "Text at position");
    }

    #[test]
    fn test_sanitize_content_private_csi() {
        // Private CSI: ESC[?...
        let content = "\x1b[?25lHidden cursor\x1b[?25h";
        let result = sanitize_content(content);
        assert_eq!(result, "Hidden cursor");
    }

    #[test]
    fn test_sanitize_content_dec_special() {
        // DEC special: ESC ( or ESC )
        let content = "\x1b(0Line drawing\x1b(B";
        let result = sanitize_content(content);
        assert_eq!(result, "Line drawing");
    }

    #[test]
    fn test_search_resolves_agent_files_to_parent() {
        let temp_dir = TempDir::new().unwrap();
        let parent_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Parent session"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
        let agent_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Agent unique content"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:01:00Z"}"#;

        // Create parent session file and an agent file with same sessionId
        create_test_session(&temp_dir, "abc123.jsonl", parent_content);
        create_test_session(&temp_dir, "agent-task1.jsonl", agent_content);

        let results = search("Agent unique content", temp_dir.path().to_str().unwrap())
            .expect("Search should succeed");

        // Match from agent file should be resolved to parent session
        assert!(!results.is_empty(), "Should find match from agent file");
        for r in &results {
            assert!(
                r.file_path.contains("abc123.jsonl"),
                "Agent match should resolve to parent file, got: {}",
                r.file_path
            );
            assert!(
                !r.file_path.contains("agent-"),
                "File path should not be the agent file, got: {}",
                r.file_path
            );
        }
    }

    #[test]
    fn test_search_resolves_subagent_files_to_parent() {
        let temp_dir = TempDir::new().unwrap();
        let parent_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Parent session"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
        let agent_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Subagent unique content"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:01:00Z"}"#;

        // Create parent session file and a subagent file
        create_test_session(&temp_dir, "abc123.jsonl", parent_content);
        let subagents_dir = temp_dir.path().join("abc123").join("subagents");
        std::fs::create_dir_all(&subagents_dir).unwrap();
        let subagent_path = subagents_dir.join("agent-xyz.jsonl");
        std::fs::write(&subagent_path, agent_content).unwrap();

        let results = search("Subagent unique content", temp_dir.path().to_str().unwrap())
            .expect("Search should succeed");

        // Match from subagent file should resolve to parent session
        assert!(!results.is_empty(), "Should find match from subagent file");
        for r in &results {
            assert!(
                r.file_path.contains("abc123.jsonl"),
                "Subagent match should resolve to parent file, got: {}",
                r.file_path
            );
            assert!(
                !r.file_path.contains("/subagents/"),
                "File path should not be the subagent file, got: {}",
                r.file_path
            );
        }
    }

    #[test]
    fn test_search_resolves_codex_subagent_rollout_to_parent() {
        let temp_dir = TempDir::new().unwrap();
        let session_dir = temp_dir.path().join(".codex/sessions/2026/05/03");
        std::fs::create_dir_all(&session_dir).unwrap();

        let parent_id = "019f0000-0000-7000-8000-000000000001";
        let child_id = "019f0000-0000-7000-8000-000000000002";
        let parent_path =
            session_dir.join(format!("rollout-2026-05-03T10-00-00-{parent_id}.jsonl"));
        let child_path = session_dir.join(format!("rollout-2026-05-03T10-01-00-{child_id}.jsonl"));

        std::fs::write(
            &parent_path,
            format!(
                r#"{{"timestamp":"2026-05-03T10:00:00Z","type":"session_meta","payload":{{"id":"{parent_id}","cwd":"/tmp/codex-demo","source":"cli"}}}}
{{"timestamp":"2026-05-03T10:00:01Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Parent prompt"}}]}}}}"#
            ),
        )
        .unwrap();
        std::fs::write(
            &child_path,
            format!(
                r#"{{"timestamp":"2026-05-03T10:01:00Z","type":"session_meta","payload":{{"id":"{child_id}","cwd":"/tmp/codex-demo","source":{{"subagent":{{"thread_spawn":{{"parent_thread_id":"{parent_id}","depth":1,"agent_nickname":"Sagan","agent_role":"default"}}}}}}}}}}
{{"timestamp":"2026-05-03T10:01:01Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Codex subagent unique content"}}]}}}}"#
            ),
        )
        .unwrap();

        let results = search(
            "Codex subagent unique content",
            temp_dir.path().join(".codex/sessions").to_str().unwrap(),
        )
        .expect("Search should succeed");

        assert!(!results.is_empty(), "Should find match from Codex subagent");
        for r in &results {
            assert_eq!(r.file_path, parent_path.to_string_lossy());
            assert_eq!(
                r.message.as_ref().map(|m| m.session_id.as_str()),
                Some(parent_id)
            );
        }
    }

    #[test]
    fn test_is_agent_or_subagent_path() {
        assert!(is_agent_or_subagent_path(
            "/home/user/.claude/projects/foo/agent-task1.jsonl"
        ));
        assert!(is_agent_or_subagent_path(
            "/home/user/.claude/projects/foo/abc123/subagents/sub.jsonl"
        ));
        assert!(!is_agent_or_subagent_path(
            "/home/user/.claude/projects/foo/session.jsonl"
        ));
        assert!(!is_agent_or_subagent_path(
            "/home/user/.claude/projects/foo/abc123.jsonl"
        ));
    }

    #[test]
    fn test_search_returns_truncated_flag_when_max_count_hit() {
        let temp_dir = TempDir::new().unwrap();

        // Create a JSONL file with MAX_COUNT_PER_FILE + 10 lines all containing the query.
        // ripgrep --max-count will stop at MAX_COUNT_PER_FILE matches, and we should detect truncation.
        let mut content = String::new();
        for i in 0..(MAX_COUNT_PER_FILE + 10) {
            content.push_str(&format!(
                r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"findme line {}"}}]}},"sessionId":"sess1","timestamp":"2025-01-09T10:00:{:02}Z"}}"#,
                i, i % 60
            ));
            content.push('\n');
        }
        create_test_session(&temp_dir, "big_session.jsonl", &content);

        let cancel = Arc::new(AtomicBool::new(false));
        let (_, truncated) = search_single_path(
            "findme",
            temp_dir.path().to_str().unwrap(),
            false,
            None,
            &cancel,
            &[],
        )
        .expect("Search should succeed");

        assert!(
            truncated,
            "Should detect truncation when file has more matches than max-count"
        );
    }

    #[test]
    fn test_search_opencode_caps_at_max_count_and_reports_truncation() {
        // Build a minimal Opencode DB where the number of matching messages
        // exceeds MAX_COUNT_PER_FILE. Each message gets its own matching
        // text part — dedupe is by (session_id, message_id), so this
        // produces MAX_COUNT_PER_FILE + N distinct hits before the cap fires.
        use rusqlite::Connection;

        let temp_dir = TempDir::new().unwrap();
        let db_path = temp_dir.path().join("opencode.db");
        let conn = Connection::open(&db_path).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT,
                name TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
                slug TEXT NOT NULL DEFAULT '', directory TEXT, title TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
                session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
                time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            INSERT INTO project VALUES ('p', '/tmp/p', 'git', 'p', 1, 2);
            INSERT INTO session VALUES ('ses_BIG', 'p', '', '/tmp/p', 't', 1, 2);
            "#,
        )
        .unwrap();

        let extra = 25usize;
        for i in 0..(MAX_COUNT_PER_FILE + extra) {
            let mid = format!("m{:05}", i);
            conn.execute(
                "INSERT INTO message VALUES (?1, 'ses_BIG', ?2, ?2, ?3)",
                rusqlite::params![
                    mid,
                    1_000_000_000_i64 + i as i64,
                    r#"{"role":"user","time":{"created":1000000000}}"#
                ],
            )
            .unwrap();
            conn.execute(
                "INSERT INTO part VALUES (?1, ?2, 'ses_BIG', ?3, ?3, ?4)",
                rusqlite::params![
                    format!("p{:05}", i),
                    mid,
                    1_000_000_000_i64 + i as i64,
                    r#"{"type":"text","text":"findme opencode"}"#
                ],
            )
            .unwrap();
        }

        let cancel = Arc::new(AtomicBool::new(false));
        let (results, truncated) =
            search_opencode_storage("findme", db_path.to_str().unwrap(), None, &cancel)
                .expect("opencode search should succeed");

        assert_eq!(
            results.len(),
            OPENCODE_PER_SESSION_CAP,
            "single-session results must be capped at OPENCODE_PER_SESSION_CAP \
             so a chatty session can't starve other sessions"
        );
        assert!(
            truncated,
            "truncated flag must be set when the cap is hit with more matches available"
        );
    }

    #[test]
    fn test_search_opencode_chatty_session_does_not_starve_others() {
        use rusqlite::Connection;

        let temp_dir = TempDir::new().unwrap();
        let db_path = temp_dir.path().join("opencode.db");
        let conn = Connection::open(&db_path).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT,
                name TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
                slug TEXT NOT NULL DEFAULT '', directory TEXT, title TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
                session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
                time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            INSERT INTO project VALUES ('p', '/tmp/p', 'git', 'p', 1, 9);
            INSERT INTO session VALUES ('ses_CHATTY', 'p', '', '/tmp/p', 't', 1, 9);
            INSERT INTO session VALUES ('ses_QUIET',  'p', '', '/tmp/p', 't', 1, 8);
            "#,
        )
        .unwrap();

        // Chatty session gets the freshest timestamps and far more than the
        // per-session cap; quiet session gets older but real matches. Under
        // the old global-only cap the quiet session would be invisible.
        let chatty_rows = OPENCODE_PER_SESSION_CAP * 4;
        for i in 0..chatty_rows {
            let mid = format!("mC{:05}", i);
            conn.execute(
                "INSERT INTO message VALUES (?1, 'ses_CHATTY', ?2, ?2, ?3)",
                rusqlite::params![
                    mid,
                    2_000_000_000_i64 + i as i64,
                    r#"{"role":"user","time":{"created":2000000000}}"#
                ],
            )
            .unwrap();
            conn.execute(
                "INSERT INTO part VALUES (?1, ?2, 'ses_CHATTY', ?3, ?3, ?4)",
                rusqlite::params![
                    format!("pC{:05}", i),
                    mid,
                    2_000_000_000_i64 + i as i64,
                    r#"{"type":"text","text":"findme opencode"}"#
                ],
            )
            .unwrap();
        }
        conn.execute(
            "INSERT INTO message VALUES ('mQ', 'ses_QUIET', 1, 1, ?1)",
            rusqlite::params![r#"{"role":"user","time":{"created":1}}"#],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO part VALUES ('pQ', 'mQ', 'ses_QUIET', 1, 1, ?1)",
            rusqlite::params![r#"{"type":"text","text":"findme opencode"}"#],
        )
        .unwrap();

        let cancel = Arc::new(AtomicBool::new(false));
        let (results, truncated) =
            search_opencode_storage("findme", db_path.to_str().unwrap(), None, &cancel)
                .expect("opencode search should succeed");

        let quiet_hits = results
            .iter()
            .filter(|m| {
                m.message
                    .as_ref()
                    .is_some_and(|msg| msg.session_id == "ses_QUIET")
            })
            .count();
        let chatty_hits = results
            .iter()
            .filter(|m| {
                m.message
                    .as_ref()
                    .is_some_and(|msg| msg.session_id == "ses_CHATTY")
            })
            .count();
        assert_eq!(
            quiet_hits, 1,
            "quiet session must surface despite chatty neighbour"
        );
        assert_eq!(
            chatty_hits, OPENCODE_PER_SESSION_CAP,
            "chatty session must be capped at per-session limit"
        );
        assert!(
            truncated,
            "truncation flag must surface the per-session drop"
        );
    }

    #[test]
    fn test_search_opencode_below_cap_does_not_flag_truncation() {
        use rusqlite::Connection;

        let temp_dir = TempDir::new().unwrap();
        let db_path = temp_dir.path().join("opencode.db");
        let conn = Connection::open(&db_path).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT,
                name TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
                slug TEXT NOT NULL DEFAULT '', directory TEXT, title TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
                session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
                time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            INSERT INTO project VALUES ('p', '/tmp/p', 'git', 'p', 1, 2);
            INSERT INTO session VALUES ('ses_SMALL', 'p', '', '/tmp/p', 't', 1, 2);
            INSERT INTO message VALUES ('m1', 'ses_SMALL', 100, 100, '{"role":"user","time":{"created":100}}');
            INSERT INTO part VALUES ('p1', 'm1', 'ses_SMALL', 100, 100, '{"type":"text","text":"findme"}');
            "#,
        )
        .unwrap();

        let cancel = Arc::new(AtomicBool::new(false));
        let (results, truncated) =
            search_opencode_storage("findme", db_path.to_str().unwrap(), None, &cancel)
                .expect("opencode search should succeed");

        assert_eq!(results.len(), 1);
        assert!(!truncated, "no truncation when matches < cap");
    }

    #[test]
    fn test_search_opencode_corrupt_db_returns_empty_not_error() {
        // A file at an `opencode.db` path that isn't a valid SQLite database
        // (corrupt header, truncated file, schema mismatch, …) must not poison
        // the mixed-provider search. The Opencode scanner returns no matches,
        // and the surrounding `search_multiple_paths_inner` keeps walking the
        // other (JSONL) search paths.
        let temp_dir = TempDir::new().unwrap();
        let db_path = temp_dir.path().join("opencode.db");
        std::fs::write(&db_path, b"this is not a sqlite database").unwrap();

        let cancel = Arc::new(AtomicBool::new(false));
        let (results, truncated) =
            search_opencode_storage("findme", db_path.to_str().unwrap(), None, &cancel)
                .expect("corrupt opencode db must not propagate as a search error");
        assert!(
            results.is_empty(),
            "corrupt opencode db should yield no matches"
        );
        assert!(!truncated);
    }

    #[test]
    fn test_search_opencode_corrupt_db_does_not_break_mixed_search() {
        // End-to-end: a JSONL search path with a real match plus an Opencode
        // path pointing at a corrupt DB. The Claude hit must still surface.
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"findme Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
        create_test_session(&temp_dir, "session.jsonl", session_content);

        let db_dir = TempDir::new().unwrap();
        let db_path = db_dir.path().join("opencode.db");
        std::fs::write(&db_path, b"not a sqlite database").unwrap();

        let cancel = Arc::new(AtomicBool::new(false));
        let result = search_multiple_paths(
            "findme",
            &[
                temp_dir.path().to_str().unwrap().to_string(),
                db_path.to_str().unwrap().to_string(),
            ],
            false,
            &cancel,
        )
        .expect("mixed search must succeed even with a broken Opencode DB");
        assert!(
            !result.matches.is_empty(),
            "Claude hit must surface despite Opencode failure"
        );
        assert!(result.matches.iter().any(|m| m
            .message
            .as_ref()
            .is_some_and(|msg| msg.content.contains("findme"))));
    }

    #[test]
    fn test_search_opencode_propagates_cancellation() {
        // A pre-set cancel token must still surface as Err("cancelled") so the
        // outer search loop can short-circuit instead of treating cancel as a
        // benign "no matches" outcome.
        use rusqlite::Connection;

        let temp_dir = TempDir::new().unwrap();
        let db_path = temp_dir.path().join("opencode.db");
        let conn = Connection::open(&db_path).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT,
                name TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
                slug TEXT NOT NULL DEFAULT '', directory TEXT, title TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
            CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
                time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
                session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
                time_updated INTEGER NOT NULL, data TEXT NOT NULL);
            "#,
        )
        .unwrap();

        let cancel = Arc::new(AtomicBool::new(true));
        let err = search_opencode_storage("findme", db_path.to_str().unwrap(), None, &cancel)
            .expect_err("pre-cancelled search must return Err");
        assert_eq!(err, "cancelled");
    }

    #[test]
    fn test_search_no_truncation_flag_for_small_results() {
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
        create_test_session(&temp_dir, "session.jsonl", session_content);

        let cancel = Arc::new(AtomicBool::new(false));
        let (_, truncated) = search_single_path(
            "Hello",
            temp_dir.path().to_str().unwrap(),
            false,
            None,
            &cancel,
            &[],
        )
        .expect("Search should succeed");

        assert!(
            !truncated,
            "Should not flag truncation for small result sets"
        );
    }

    #[test]
    fn test_search_multiple_paths_cancel_before_invocation_returns_immediately() {
        // When the cancel flag is already set, the function must return Err("cancelled")
        // without spawning ripgrep. We rely on the early-exit check at the top of
        // search_multiple_paths — if it weren't there the bogus path below would still
        // be silently skipped (existence check), so we add a real path too: even when
        // a real fixture is present, no work should happen because cancel was preset.
        let temp_dir = TempDir::new().unwrap();
        let session_content = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
        create_test_session(&temp_dir, "session.jsonl", session_content);

        let cancel = Arc::new(AtomicBool::new(true));
        let start = std::time::Instant::now();
        let result = search_multiple_paths(
            "Hello",
            &[temp_dir.path().to_str().unwrap().to_string()],
            false,
            &cancel,
        );
        let elapsed = start.elapsed();

        assert!(result.is_err(), "Pre-cancelled search must return Err");
        let err = result.err().unwrap();
        assert_eq!(err, "cancelled", "unexpected error: {}", err);
        // Pre-cancelled path should be effectively instant (no rg spawn).
        assert!(
            elapsed < std::time::Duration::from_millis(50),
            "Pre-cancelled search took too long: {:?}",
            elapsed
        );
    }

    #[test]
    fn test_search_mid_flight_cancellation_kills_child() {
        // Build a large fixture so an uncancelled run takes long enough for the
        // cancel-mid-flight check to be meaningful. We spread matching lines across
        // many files (ripgrep's --max-count caps per-file output at MAX_COUNT_PER_FILE,
        // so a single huge file would simply truncate quickly). With 60 files of
        // ~MAX_COUNT_PER_FILE matching lines each, ripgrep streams ~60k JSON
        // records that we parse + post-filter in user space — wall-clock for this
        // is on the order of 100 ms even on M-series CPUs in release mode.
        let temp_dir = TempDir::new().unwrap();
        let line_template = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"findme PADDING_PADDING_PADDING_PADDING line "#;
        for f in 0..60 {
            let mut content = String::with_capacity(200 * MAX_COUNT_PER_FILE);
            for i in 0..MAX_COUNT_PER_FILE {
                content.push_str(line_template);
                content.push_str(&format!(
                    r#"{}-{}"}}]}},"sessionId":"sess{}","timestamp":"2025-01-09T10:00:00Z"}}"#,
                    f, i, f
                ));
                content.push('\n');
            }
            create_test_session(&temp_dir, &format!("huge_{:02}.jsonl", f), &content);
        }

        // Measure baseline so we can reason about whether the cancel actually
        // stopped work instead of arriving after the search naturally finished.
        let baseline_cancel = Arc::new(AtomicBool::new(false));
        let baseline_start = std::time::Instant::now();
        let baseline = search_multiple_paths(
            "findme",
            &[temp_dir.path().to_str().unwrap().to_string()],
            false,
            &baseline_cancel,
        )
        .expect("baseline search should succeed");
        let baseline_elapsed = baseline_start.elapsed();
        assert!(
            !baseline.matches.is_empty(),
            "fixture should produce matches"
        );

        // Sanity-check that the baseline was substantial enough for our
        // cancel-during-iteration window to be meaningful. If the baseline
        // is somehow under 20 ms (truly extreme hardware), the test below
        // would race; surface that with a clear message rather than a
        // confusing cancel-not-observed failure.
        assert!(
            baseline_elapsed >= std::time::Duration::from_millis(20),
            "fixture is too small for the cancel-mid-flight assertion to be meaningful (baseline={:?}); enlarge the fixture",
            baseline_elapsed
        );

        let cancel = Arc::new(AtomicBool::new(false));
        let cancel_for_worker = cancel.clone();
        let path = temp_dir.path().to_str().unwrap().to_string();

        let handle = std::thread::spawn(move || {
            search_multiple_paths("findme", &[path], false, &cancel_for_worker)
        });

        // Cancel after a fraction of the observed baseline (or 1 ms,
        // whichever is larger). The fraction ensures even fast hosts give
        // the worker enough time to spawn `rg` and start the read loop —
        // we want to land inside `BufReader::lines()`, not before the
        // child has been spawned. 1/4 of baseline is conservative; the
        // baseline assertion above guarantees this is at least 5 ms.
        let cancel_after = std::cmp::max(std::time::Duration::from_millis(1), baseline_elapsed / 4);
        std::thread::sleep(cancel_after);
        cancel.store(true, Ordering::Relaxed);

        let join_start = std::time::Instant::now();
        let result = handle.join().expect("worker thread panicked");
        let join_elapsed = join_start.elapsed();

        assert!(
            result.is_err(),
            "Mid-flight cancelled search must return Err (baseline took {:?}, cancel_after={:?}, join took {:?})",
            baseline_elapsed,
            cancel_after,
            join_elapsed
        );
        assert_eq!(result.err().unwrap(), "cancelled");
        // After cancel, the worker should drop out of its read loop quickly because
        // the rg child was killed and stdout EOF'd. The deadline is a generous
        // 500 ms — the actual time is typically a couple of ms.
        assert!(
            join_elapsed < std::time::Duration::from_millis(500),
            "Cancelled search did not unwind quickly: join took {:?}, baseline {:?}, cancel_after {:?}",
            join_elapsed,
            baseline_elapsed,
            cancel_after
        );
    }

    #[test]
    fn test_search_watchdog_cancels_when_no_matches_emitted() {
        // Regression for a stuck-cancel bug: when `rg` is busy scanning a
        // corpus that produces *no* matches, the per-line cancel check
        // inside `BufReader::lines()` never fires because `read_line()`
        // blocks waiting for output that never comes. Without the 50 ms
        // watchdog the user would see "Searching…" until rg exits
        // naturally even after cancelling — wasted work and battery.
        //
        // The contract this test pins: after `cancel.store(true)`, the
        // worker thread must return *promptly* (≪ natural scan time)
        // with Err("cancelled"). The Err vs Ok distinction alone is not
        // enough to validate the watchdog (the post-loop cancel check
        // in `search_single_path` would convert a natural-completion
        // Ok into Err("cancelled") even without the watchdog) — the
        // *responsiveness* assertion is what proves the watchdog
        // actually killed rg mid-scan.
        //
        // Determinism strategy: instead of a multi-GB fixture (which is
        // brittle on slow disks and slow CI), we exploit per-file `open`
        // overhead by creating many tiny files and forcing single-thread
        // rg. With ~10000 small files, file traversal dominates rg's
        // wall-clock and reliably pushes probe_elapsed past 100 ms even
        // on fast hardware (M-series), without burning gigabytes of
        // disk space. Throughput-based slowdown is unreliable because rg
        // scans hundreds of MB/s even with --threads 1 on hot pages.
        let temp_dir = TempDir::new().unwrap();
        const FILE_COUNT: usize = 20_000;
        let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"haystack line"}]},"sessionId":"sess1","timestamp":"2025-01-09T10:00:00Z"}"#;
        // ~120 bytes per file × 20k = ~2.4 MB content + filesystem
        // block overhead (~80 MB on common filesystems with 4 KB blocks).
        for f in 0..FILE_COUNT {
            create_test_session(&temp_dir, &format!("nomatch_{:05}.jsonl", f), line);
        }

        // Force single-threaded rg so per-file overhead dominates and
        // timing is reproducible across hosts.
        let extra_args: Vec<String> = vec!["--threads".to_string(), "1".to_string()];

        // Warm up the directory entry cache so the probe and the
        // cancellation run are both hot — eliminates cold-vs-hot timing
        // skew that would otherwise make the cancel run appear faster
        // than the watchdog actually achieved.
        let warmup_cancel = Arc::new(AtomicBool::new(false));
        let _ = search_multiple_paths_with_extra_args(
            "ZZZ_NEVER_MATCHES_ZZZ",
            &[temp_dir.path().to_str().unwrap().to_string()],
            false,
            &warmup_cancel,
            &extra_args,
        );

        // Probe natural scan time on hot pages.
        let probe_cancel = Arc::new(AtomicBool::new(false));
        let probe_start = std::time::Instant::now();
        let probe = search_multiple_paths_with_extra_args(
            "ZZZ_NEVER_MATCHES_ZZZ",
            &[temp_dir.path().to_str().unwrap().to_string()],
            false,
            &probe_cancel,
            &extra_args,
        )
        .expect("probe should succeed (no matches is success)");
        let probe_elapsed = probe_start.elapsed();
        assert!(
            probe.matches.is_empty(),
            "fixture should produce no matches"
        );
        assert!(
            probe_elapsed >= std::time::Duration::from_millis(100),
            "fixture is too small: probe_elapsed={:?} (need ≥ 100 ms for the responsiveness assertion to discriminate). \
             Increase FILE_COUNT.",
            probe_elapsed
        );

        // Run the cancellation experiment. Cancel-after = 20 ms is a
        // small fraction of expected scan time even on fast hosts.
        let cancel_after = std::time::Duration::from_millis(20);
        let cancel = Arc::new(AtomicBool::new(false));
        let cancel_for_worker = cancel.clone();
        let path = temp_dir.path().to_str().unwrap().to_string();
        let extra_for_worker = extra_args.clone();

        let spawn_start = std::time::Instant::now();
        let handle = std::thread::spawn(move || {
            search_multiple_paths_with_extra_args(
                "ZZZ_NEVER_MATCHES_ZZZ",
                &[path],
                false,
                &cancel_for_worker,
                &extra_for_worker,
            )
        });

        std::thread::sleep(cancel_after);
        cancel.store(true, Ordering::Relaxed);

        let result = handle.join().expect("worker thread panicked");
        let total_elapsed = spawn_start.elapsed();

        // Contract: result must be Err("cancelled"). This is enforced
        // by the post-loop cancel check regardless of whether the
        // watchdog fired, but it pins the contract that cancellation
        // does not silently drop into Ok.
        match &result {
            Err(msg) => assert_eq!(
                msg, "cancelled",
                "expected Err(\"cancelled\"), got: {}",
                msg
            ),
            Ok(_) => {
                panic!("watchdog regression: cancel returned Ok instead of Err(\"cancelled\")")
            }
        }

        // Responsiveness contract: total wall-clock must be substantially
        // below the natural probe time. If the watchdog were absent and
        // the per-line cancel check never fired (the bug scenario),
        // `total_elapsed` would be ≈ probe_elapsed because the worker
        // would have to wait for natural rg completion. We require
        // total_elapsed < probe_elapsed / 2 — strict enough to catch a
        // broken watchdog (which would push total ≈ probe), tolerant of
        // the 50 ms watchdog poll cadence + 20 ms cancel_after sleep.
        let max_total = probe_elapsed / 2;
        assert!(
            total_elapsed < max_total,
            "Cancelled search took {:?}, expected < {:?} (probe_elapsed={:?}, cancel_after={:?}); \
             watchdog likely did not observe the cancel flag in time — search ran to natural completion",
            total_elapsed,
            max_total,
            probe_elapsed,
            cancel_after
        );
    }

    /// Probe the actual pipe buffer size on Linux via `F_GETPIPE_SZ` so
    /// the chatty-stderr test can scale its fixture accordingly. On
    /// non-Linux unix (macOS, BSD) and on probe failures the canonical
    /// 64 KiB Linux default is returned.
    #[cfg(unix)]
    fn probe_pipe_buffer_bytes() -> usize {
        const DEFAULT_PIPE_BUF: usize = 64 * 1024;
        #[cfg(target_os = "linux")]
        {
            use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
            // SAFETY: `pipe2` writes two valid file descriptors into `fds`
            // on success. We immediately wrap them in `OwnedFd` so they
            // are closed on drop even if `fcntl` fails.
            let mut fds: [libc::c_int; 2] = [0; 2];
            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
            if rc != 0 {
                return DEFAULT_PIPE_BUF;
            }
            let _read = unsafe { OwnedFd::from_raw_fd(fds[0]) };
            let write = unsafe { OwnedFd::from_raw_fd(fds[1]) };
            let size = unsafe { libc::fcntl(write.as_raw_fd(), libc::F_GETPIPE_SZ) };
            if size > 0 {
                size as usize
            } else {
                DEFAULT_PIPE_BUF
            }
        }
        #[cfg(not(target_os = "linux"))]
        {
            DEFAULT_PIPE_BUF
        }
    }

    #[test]
    fn test_search_does_not_deadlock_on_chatty_stderr() {
        // Regression for a stderr-deadlock: when `rg` produces enough
        // stderr output to fill the pipe buffer, it blocks on `write()`
        // until something drains the pipe. The wrapper must drain stderr
        // concurrently with stdout, otherwise the child hangs forever and
        // `child.wait()` never returns.
        //
        // We trigger chatty stderr by creating many `*.jsonl` files with
        // mode `0o000` so the user (running this test) cannot read them.
        // `rg` prints "Permission denied (os error 13)" once per file.
        // The number of files is sized off the *actual* pipe buffer
        // (probed via `F_GETPIPE_SZ` on Linux, falling back to the
        // canonical 64 KiB default on macOS/BSD/probe failure) so the
        // fixture deterministically clears the buffer even on hosts with
        // larger pipes (some BSDs configure 1 MiB).
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;

            let temp_dir = TempDir::new().unwrap();
            // Verify chmod 0o000 actually blocks reads for the test
            // runner (root bypasses POSIX read perms; in that case rg
            // produces no Permission-denied lines and the deadlock
            // condition cannot be triggered, so we skip).
            let probe = temp_dir.path().join("__perm_probe.jsonl");
            std::fs::write(&probe, "{}\n").unwrap();
            let mut probe_perms = std::fs::metadata(&probe).unwrap().permissions();
            probe_perms.set_mode(0o000);
            std::fs::set_permissions(&probe, probe_perms).unwrap();
            let probe_readable = std::fs::read_to_string(&probe).is_ok();
            // Restore so the file can be deleted later by TempDir.
            let mut restore = std::fs::metadata(&probe).unwrap().permissions();
            restore.set_mode(0o644);
            let _ = std::fs::set_permissions(&probe, restore);
            let _ = std::fs::remove_file(&probe);
            if probe_readable {
                eprintln!(
                    "skipping test_search_does_not_deadlock_on_chatty_stderr: chmod 0o000 did not block reads (likely running as root)"
                );
                return;
            }

            // Each `rg` "Permission denied" line is roughly 80 bytes
            // (path + boilerplate). Write ~2x the actual pipe buffer so
            // we are guaranteed to exceed it even on systems with larger
            // pipes. Floor at 1500 files to preserve the original
            // fixture's strength on the common 64 KiB Linux/macOS case.
            const APPROX_LINE_BYTES: usize = 80;
            let pipe_buf = probe_pipe_buffer_bytes();
            let target_stderr_bytes = pipe_buf.saturating_mul(2);
            let file_count = (target_stderr_bytes / APPROX_LINE_BYTES).max(1500);
            for i in 0..file_count {
                let path = temp_dir.path().join(format!(
                    "unreadable_with_a_long_filename_to_pad_stderr_{:06}.jsonl",
                    i
                ));
                std::fs::write(&path, "{\"type\":\"summary\"}\n").unwrap();
                let mut perms = std::fs::metadata(&path).unwrap().permissions();
                perms.set_mode(0o000);
                std::fs::set_permissions(&path, perms).unwrap();
            }

            let cancel = Arc::new(AtomicBool::new(false));
            let start = std::time::Instant::now();
            // The search itself will not match anything but must complete.
            // The 30 s deadline is generous; the actual time on a working
            // implementation is well under 1 s. Without the stderr
            // drain, the old code blocked indefinitely.
            let result = search_multiple_paths(
                "findme",
                &[temp_dir.path().to_str().unwrap().to_string()],
                false,
                &cancel,
            );
            let elapsed = start.elapsed();

            // Restore permissions so TempDir cleanup can remove the files.
            // Per-entry errors are ignored so a single failure does not
            // panic the loop and leave subsequent files unreadable, which
            // would break TempDir cleanup.
            if let Ok(entries) = std::fs::read_dir(temp_dir.path()) {
                for entry in entries.flatten() {
                    if let Ok(meta) = std::fs::metadata(entry.path()) {
                        let mut perms = meta.permissions();
                        perms.set_mode(0o644);
                        let _ = std::fs::set_permissions(entry.path(), perms);
                    }
                }
            }

            assert!(
                elapsed < std::time::Duration::from_secs(30),
                "search hung — likely a stderr-pipe deadlock (took {:?})",
                elapsed
            );
            // The unreadable files cause `rg` to exit with a non-zero
            // status; we must surface that as an Err with the captured
            // stderr text included (regression check for the previous
            // opaque "exit status N" message).
            match result {
                Err(msg) => {
                    assert!(
                        msg.contains("exit status"),
                        "error must mention exit status, got: {}",
                        msg
                    );
                    assert!(
                        msg.to_lowercase().contains("permission") || msg.contains("os error 13"),
                        "error must include captured stderr text, got: {}",
                        msg
                    );
                }
                Ok(_) => {
                    // Some rg builds may treat read errors as exit code 0
                    // with warnings on stderr only. In that case the
                    // test still proves no deadlock occurred, which is
                    // the primary regression we are guarding against.
                }
            }
        }
        #[cfg(not(unix))]
        {
            // chmod 0o000 is meaningless on non-unix platforms; the
            // deadlock guarantee is enforced by the same code path
            // regardless. Skip rather than introduce a Windows-specific
            // stderr-flooding fixture.
            eprintln!("skipping test_search_does_not_deadlock_on_chatty_stderr: non-unix platform");
        }
    }
}
