//! Code search — full-text search across project files using the `ignore` crate.
use crate::slog;
use crate::state::SessionState;
use ignore::WalkBuilder;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;

#[derive(Serialize, Debug)]
/// A single file that matched a text search, with its match count.
pub struct SearchResult {
    pub path: String,
    pub matches: usize,
}

pub async fn search_files(
    query: String,
    state: &SessionState,
) -> Result<Vec<SearchResult>, String> {
    let root = state.get_project_root()?;
    search_files_impl(query, root).await
}

pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<SearchResult>, String> {
    let root_clone = root.clone();

    let results = tokio::task::spawn_blocking(move || {
        let mut matches = Vec::new();
        let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();

        for result in walker {
            match result {
                Ok(entry) => {
                    if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
                        continue;
                    }

                    let path = entry.path();
                    if let Ok(content) = fs::read_to_string(path)
                        && content.contains(&query)
                    {
                        let relative = path
                            .strip_prefix(&root_clone)
                            .unwrap_or(path)
                            .to_string_lossy()
                            .to_string();

                        matches.push(SearchResult {
                            path: relative,
                            matches: 1,
                        });
                    }
                }
                Err(err) => slog!("Error walking dir: {}", err),
            }
        }

        matches
    })
    .await
    .map_err(|e| format!("Search task failed: {e}"))?;

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::io::test_helpers::create_test_files;
    use tempfile::TempDir;

    fn setup_project(files: &[(&str, &str)]) -> TempDir {
        let dir = TempDir::new().unwrap();
        create_test_files(&dir, files);
        dir
    }

    #[tokio::test]
    async fn finds_files_matching_query() {
        let dir = setup_project(&[
            ("hello.txt", "hello world"),
            ("goodbye.txt", "goodbye world"),
        ]);

        let results = search_files_impl("hello".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].path, "hello.txt");
    }

    #[tokio::test]
    async fn returns_empty_for_no_matches() {
        let dir = setup_project(&[("file.txt", "some content")]);

        let results = search_files_impl("nonexistent".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert!(results.is_empty());
    }

    #[tokio::test]
    async fn searches_nested_directories() {
        let dir = setup_project(&[
            ("top.txt", "needle"),
            ("sub/deep.txt", "needle in haystack"),
            ("sub/other.txt", "no match here"),
        ]);

        let results = search_files_impl("needle".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert_eq!(results.len(), 2);
        let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
        assert!(paths.contains(&"top.txt"));
        assert!(paths.contains(&"sub/deep.txt"));
    }

    #[tokio::test]
    async fn skips_directories_only_matches_files() {
        let dir = setup_project(&[("sub/file.txt", "content")]);

        let results = search_files_impl("content".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].path, "sub/file.txt");
    }

    #[tokio::test]
    async fn respects_gitignore() {
        let dir = setup_project(&[
            (".gitignore", "ignored/\n"),
            ("kept.txt", "search term"),
            ("ignored/hidden.txt", "search term"),
        ]);

        // Initialize a git repo so .gitignore is respected
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(dir.path())
            .output()
            .unwrap();

        let results = search_files_impl("search term".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].path, "kept.txt");
    }

    #[tokio::test]
    async fn search_files_with_session_state() {
        let dir = setup_project(&[("found.txt", "target_text")]);
        let state = SessionState::default();
        *state.project_root.lock().unwrap() = Some(dir.path().to_path_buf());

        let results = search_files("target_text".to_string(), &state)
            .await
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].path, "found.txt");
    }

    #[tokio::test]
    async fn search_files_errors_without_project_root() {
        let state = SessionState::default();

        let result = search_files("query".to_string(), &state).await;

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("No project"));
    }

    #[test]
    fn search_result_serializes_and_debugs() {
        let sr = SearchResult {
            path: "src/main.rs".to_string(),
            matches: 3,
        };
        let json = serde_json::to_string(&sr).unwrap();
        assert!(json.contains("src/main.rs"));
        assert!(json.contains("3"));

        let debug = format!("{sr:?}");
        assert!(debug.contains("SearchResult"));
        assert!(debug.contains("src/main.rs"));
    }

    #[tokio::test]
    async fn skips_binary_files() {
        let dir = TempDir::new().unwrap();
        // Write a file with invalid UTF-8 bytes
        let binary_path = dir.path().join("binary.bin");
        fs::write(&binary_path, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
        // Write a valid text file with the search term
        fs::write(dir.path().join("text.txt"), "findme").unwrap();

        let results = search_files_impl("findme".to_string(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].path, "text.txt");
    }
}
