//! Regression tests for literal text search match ordering, path filters, and manifest-scoped candidate limits.

use super::*;

#[test]
fn literal_search_returns_sorted_deterministic_matches() -> FriggResult<()> {
    let root_a = temp_workspace_root("literal-search-sort-a");
    let root_b = temp_workspace_root("literal-search-sort-b");
    prepare_workspace(
        &root_a,
        &[
            ("zeta.txt", "needle zeta\n"),
            ("alpha.txt", "needle alpha\nnext needle\n"),
        ],
    )?;
    prepare_workspace(&root_b, &[("beta.txt", "beta needle\n")])?;

    let config = FriggConfig::from_workspace_roots(vec![root_b.clone(), root_a.clone()])?;
    let searcher = TextSearcher::new(config);
    let query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 100,
    };

    let first = searcher.search(query.clone())?;
    let second = searcher.search(query)?;
    assert_eq!(first, second);
    assert_eq!(
        first,
        vec![
            text_match("repo-001", "beta.txt", 1, 6, "beta needle"),
            text_match("repo-002", "alpha.txt", 1, 1, "needle alpha"),
            text_match("repo-002", "alpha.txt", 2, 6, "next needle"),
            text_match("repo-002", "zeta.txt", 1, 1, "needle zeta"),
        ]
    );

    cleanup_workspace(&root_a);
    cleanup_workspace(&root_b);
    Ok(())
}

#[test]
fn literal_search_repository_filter_accepts_stable_repository_id_alias() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-stable-repository-filter");
    prepare_workspace(
        &root,
        &[(
            "src/main.rs",
            "pub fn submissions_open() { println!(\"needle\"); }\n",
        )],
    )?;

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let matches = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "needle".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters {
            repository_id: Some(crate::domain::model::RepositoryId::for_root(&root).0),
            language: None,
        },
    )?;

    assert_eq!(matches.len(), 1);
    assert_eq!(matches[0].path, "src/main.rs");
    assert_eq!(matches[0].repository_id, "repo-001");

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_walk_fallback_respects_gitignored_contract_artifacts() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-gitignored-contracts");
    prepare_workspace(&root, &[("contracts/errors.md", "invalid_params\n")])?;
    fs::write(root.join(".gitignore"), "contracts\n").map_err(FriggError::Io)?;

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let matches = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "invalid_params".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters::default(),
    )?;

    assert!(
        matches
            .iter()
            .all(|entry| entry.path != "contracts/errors.md"),
        "walk fallback should respect gitignored contract artifacts"
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_scrubs_generic_markdown_leading_comment_metadata() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-markdown-leading-comment");
    prepare_workspace(
        &root,
        &[(
            "docs/guide.md",
            "<!-- hidden metadata secret-token -->\n# Guide\npublic content\n",
        )],
    )?;

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let hidden = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "secret-token".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters::default(),
    )?;
    assert!(
        hidden.is_empty(),
        "leading markdown comment metadata should not pollute literal search: {:?}",
        hidden
    );

    let public = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "public".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters::default(),
    )?;
    assert_eq!(public.len(), 1);
    assert_eq!(public[0].path, "docs/guide.md");
    assert_eq!(public[0].line, 3);

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_walk_fallback_excludes_target_artifacts_without_gitignore() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-target-exclusion");
    prepare_workspace(
        &root,
        &[
            ("src/main.rs", "needle\n"),
            ("target/debug/app", "needle\n"),
        ],
    )?;

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let matches = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "needle".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters::default(),
    )?;

    assert!(
        matches
            .iter()
            .all(|entry| !entry.path.starts_with("target/")),
        "walk fallback must not search target artifacts: {matches:?}"
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_walk_fallback_respects_root_ignore_file_for_auxiliary_trees() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-root-ignore");
    prepare_workspace(
        &root,
        &[
            ("src/main.rs", "needle main\n"),
            ("auxiliary/embedded-repo/src/lib.rs", "needle auxiliary\n"),
        ],
    )?;
    fs::write(root.join(".ignore"), "auxiliary/\n").map_err(FriggError::Io)?;

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let matches = searcher.search_literal_with_filters(
        SearchTextQuery {
            query: "needle".to_owned(),
            path_regex: None,
            limit: 10,
        },
        SearchFilters::default(),
    )?;

    assert_eq!(
        matches,
        vec![text_match("repo-001", "src/main.rs", 1, 1, "needle main")]
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_applies_path_regex_filter() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-path-filter");
    prepare_workspace(
        &root,
        &[
            ("src/lib.rs", "needle here\n"),
            ("README.md", "needle docs\n"),
        ],
    )?;

    let mut config = FriggConfig::from_workspace_roots(vec![root.clone()])?;
    config.lexical_runtime.backend = LexicalBackendMode::Native;
    let searcher = TextSearcher::new(config);
    let query =
        SearchTextQuery {
            query: "needle".to_owned(),
            path_regex: Some(Regex::new(r"^src/.*\.rs$").map_err(|err| {
                FriggError::InvalidInput(format!("invalid test path regex: {err}"))
            })?),
            limit: 100,
        };

    let matches = searcher.search(query)?;
    assert_eq!(
        matches,
        vec![text_match("repo-001", "src/lib.rs", 1, 1, "needle here")]
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_applies_repository_filter_and_limit_after_sorting() -> FriggResult<()> {
    let root_a = temp_workspace_root("literal-search-repo-filter-a");
    let root_b = temp_workspace_root("literal-search-repo-filter-b");
    prepare_workspace(&root_a, &[("a.txt", "needle a\nneedle aa\n")])?;
    prepare_workspace(&root_b, &[("b.txt", "needle b\nneedle bb\n")])?;

    let config = FriggConfig::from_workspace_roots(vec![root_a.clone(), root_b.clone()])?;
    let searcher = TextSearcher::new(config);
    let query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 1,
    };

    let matches = searcher.search_literal(query, Some("repo-002"))?;
    assert_eq!(
        matches,
        vec![text_match("repo-002", "b.txt", 1, 1, "needle b")]
    );

    cleanup_workspace(&root_a);
    cleanup_workspace(&root_b);
    Ok(())
}

#[test]
fn literal_search_small_limit_matches_sorted_prefix_of_full_results() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-small-limit-prefix");
    prepare_workspace(
        &root,
        &[
            ("z.txt", "needle zeta\n"),
            ("a.txt", "needle alpha\nneedle again\n"),
            ("nested/b.txt", "prefix needle\nneedle suffix\n"),
        ],
    )?;

    let mut config = FriggConfig::from_workspace_roots(vec![root.clone()])?;
    config.lexical_runtime.backend = LexicalBackendMode::Native;
    let searcher = TextSearcher::new(config);
    let full_query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 100,
    };
    let limited_query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 3,
    };

    let full = searcher.search_literal_with_filters(full_query, SearchFilters::default())?;
    let first_limited =
        searcher.search_literal_with_filters(limited_query.clone(), SearchFilters::default())?;
    let second_limited =
        searcher.search_literal_with_filters(limited_query, SearchFilters::default())?;

    assert_eq!(first_limited, second_limited);
    assert_eq!(
        first_limited,
        full.into_iter().take(3).collect::<Vec<_>>(),
        "limited search should match deterministic sorted prefix"
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn diagnostics_literal_search_reports_read_failures_deterministically() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-diagnostics-read-failure");
    fs::create_dir_all(root.join("src")).map_err(FriggError::Io)?;
    fs::write(
        root.join("src/good.rs"),
        "pub fn hotspot() { let _ = \"needle_hotspot\"; }\n",
    )
    .map_err(FriggError::Io)?;
    let unreadable_path = root.join("src/bad.rs");
    fs::write(&unreadable_path, "pub fn hidden() {}\n").map_err(FriggError::Io)?;
    #[cfg(unix)]
    {
        let mut permissions = fs::metadata(&unreadable_path)
            .map_err(FriggError::Io)?
            .permissions();
        permissions.set_mode(0o000);
        fs::set_permissions(&unreadable_path, permissions).map_err(FriggError::Io)?;
    }

    let config = FriggConfig::from_workspace_roots(vec![root.clone()])?;
    let searcher = TextSearcher::new(config);
    let query = SearchTextQuery {
        query: "needle_hotspot".to_owned(),
        path_regex: None,
        limit: 20,
    };

    let first = searcher
        .search_literal_with_filters_diagnostics(query.clone(), SearchFilters::default())?;
    let second =
        searcher.search_literal_with_filters_diagnostics(query, SearchFilters::default())?;

    assert_eq!(first.matches, second.matches);
    assert_eq!(first.matches.len(), 1);
    assert_eq!(first.matches[0].repository_id, "repo-001");
    assert_eq!(first.matches[0].path, "src/good.rs");

    assert_eq!(first.diagnostics.entries, second.diagnostics.entries);
    assert_eq!(first.diagnostics.total_count(), 1);
    assert_eq!(
        first.diagnostics.count_by_kind(SearchDiagnosticKind::Read),
        1
    );
    assert_eq!(
        first.diagnostics.count_by_kind(SearchDiagnosticKind::Walk),
        0
    );
    assert_eq!(first.diagnostics.entries[0].repository_id, "repo-001");
    assert_eq!(
        first.diagnostics.entries[0].path.as_deref(),
        Some("src/bad.rs")
    );
    assert_eq!(
        first.diagnostics.entries[0].kind,
        SearchDiagnosticKind::Read
    );
    assert!(
        !first.diagnostics.entries[0].message.is_empty(),
        "diagnostic message should be populated for read failures"
    );

    #[cfg(unix)]
    {
        let mut permissions = fs::metadata(&unreadable_path)
            .map_err(FriggError::Io)?
            .permissions();
        permissions.set_mode(0o644);
        fs::set_permissions(&unreadable_path, permissions).map_err(FriggError::Io)?;
    }
    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn diagnostics_literal_search_reports_manifest_storage_errors() -> FriggResult<()> {
    let root = temp_workspace_root("literal-search-diagnostics-manifest-storage-error");
    prepare_workspace(
        &root,
        &[(
            "src/lib.rs",
            "pub fn fallback_walk_still_finds_needle() {}\n",
        )],
    )?;

    let db_path = resolve_provenance_db_path(&root)?;
    fs::create_dir_all(
        db_path
            .parent()
            .expect("resolved provenance path should have a parent"),
    )
    .map_err(FriggError::Io)?;
    let conn = rusqlite::Connection::open(&db_path).map_err(|err| {
        FriggError::Internal(format!(
            "failed to create incompatible search fixture db: {err}"
        ))
    })?;
    conn.execute_batch(
        r#"
        CREATE TABLE schema_version (
          id INTEGER PRIMARY KEY CHECK (id = 1),
          version INTEGER NOT NULL,
          updated_at TEXT NOT NULL
        );
        INSERT INTO schema_version (id, version, updated_at)
        VALUES (1, 0, CURRENT_TIMESTAMP);
        "#,
    )
    .map_err(|err| {
        FriggError::Internal(format!(
            "failed to seed incompatible search fixture db: {err}"
        ))
    })?;
    drop(conn);

    let searcher = TextSearcher::new(FriggConfig::from_workspace_roots(vec![root.clone()])?);
    let output = searcher.search_literal_with_filters_diagnostics(
        SearchTextQuery {
            query: "fallback_walk_still_finds_needle".to_owned(),
            path_regex: None,
            limit: 20,
        },
        SearchFilters::default(),
    )?;

    assert_eq!(output.matches.len(), 1);
    assert_eq!(output.matches[0].path, "src/lib.rs");
    assert_eq!(
        output.diagnostics.count_by_kind(SearchDiagnosticKind::Read),
        1
    );
    let diagnostic = output
        .diagnostics
        .entries
        .iter()
        .find(|entry| {
            entry
                .message
                .contains("failed to read validated manifest snapshot from storage")
        })
        .expect("manifest storage error should be surfaced as a read diagnostic");
    assert!(
        diagnostic
            .message
            .contains("storage schema is incompatible"),
        "diagnostic should preserve the actionable storage error, got {:?}",
        diagnostic.message
    );

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_reuses_validated_manifest_candidates_across_repeated_queries() -> FriggResult<()>
{
    let root = temp_workspace_root("literal-search-manifest-cache-hit");
    prepare_workspace(
        &root,
        &[("src/lib.rs", "pub fn cached() { let _ = \"needle\"; }\n")],
    )?;
    seed_manifest_snapshot(&root, "repo-001", "snapshot-001", &["src/lib.rs"])?;

    let cache = Arc::new(RwLock::new(ValidatedManifestCandidateCache::default()));
    let searcher = TextSearcher::with_validated_manifest_candidate_cache(
        FriggConfig::from_workspace_roots(vec![root.clone()])?,
        Arc::clone(&cache),
    );
    let query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 10,
    };

    let first = searcher
        .search_literal_with_filters_diagnostics(query.clone(), SearchFilters::default())?;
    let second =
        searcher.search_literal_with_filters_diagnostics(query, SearchFilters::default())?;

    assert_eq!(first.matches, second.matches);
    assert_eq!(first.matches.len(), 1);
    let stats = cache
        .read()
        .expect("validated manifest candidate cache should not be poisoned")
        .stats();
    assert_eq!(stats.misses, 1);
    assert_eq!(stats.hits, 1);
    assert_eq!(stats.dirty_bypasses, 0);

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_dirty_validated_manifest_cache_falls_back_to_walk_for_new_files()
-> FriggResult<()> {
    let root = temp_workspace_root("literal-search-manifest-cache-dirty");
    prepare_workspace(&root, &[("src/lib.rs", "pub fn cached() {}\n")])?;
    seed_manifest_snapshot(&root, "repo-001", "snapshot-001", &["src/lib.rs"])?;

    let cache = Arc::new(RwLock::new(ValidatedManifestCandidateCache::default()));
    let searcher = TextSearcher::with_validated_manifest_candidate_cache(
        FriggConfig::from_workspace_roots(vec![root.clone()])?,
        Arc::clone(&cache),
    );
    let query = SearchTextQuery {
        query: "needle".to_owned(),
        path_regex: None,
        limit: 10,
    };

    let first = searcher
        .search_literal_with_filters_diagnostics(query.clone(), SearchFilters::default())?;
    assert_eq!(first.matches.len(), 0);

    prepare_workspace(
        &root,
        &[("src/new.rs", "pub fn fresh() { let _ = \"needle\"; }\n")],
    )?;
    cache
        .write()
        .expect("validated manifest candidate cache should not be poisoned")
        .mark_dirty_root(&root);

    let second =
        searcher.search_literal_with_filters_diagnostics(query, SearchFilters::default())?;

    assert_eq!(second.matches.len(), 1);
    assert_eq!(second.matches[0].path, "src/new.rs");
    let stats = cache
        .read()
        .expect("validated manifest candidate cache should not be poisoned")
        .stats();
    assert_eq!(stats.misses, 1);
    assert_eq!(stats.hits, 0);
    assert_eq!(stats.dirty_bypasses, 1);

    cleanup_workspace(&root);
    Ok(())
}

#[test]
fn literal_search_low_limit_large_corpus_matches_sorted_prefix_deterministically() -> FriggResult<()>
{
    const FILE_COUNT: usize = 96;
    const LIMIT: usize = 5;

    let root = temp_workspace_root("literal-search-large-corpus-low-limit");
    fs::create_dir_all(root.join("src/nested")).map_err(FriggError::Io)?;
    for file_idx in 0..FILE_COUNT {
        let relative = if file_idx % 2 == 0 {
            format!("src/file_{file_idx:03}.rs")
        } else {
            format!("src/nested/file_{file_idx:03}.rs")
        };
        let mut lines = Vec::with_capacity(40);
        lines.push(format!(
            "// deterministic large-corpus fixture file={file_idx:03}"
        ));
        for line_idx in 0..36 {
            if line_idx % 4 == 0 {
                lines.push(format!(
                    "let hotspot_{line_idx:03} = \"needle_hotspot {file_idx} {line_idx}\";"
                ));
            } else {
                lines.push(format!(
                    "let filler_{line_idx:03} = {};",
                    file_idx + line_idx
                ));
            }
        }
        fs::write(root.join(relative), lines.join("\n")).map_err(FriggError::Io)?;
    }

    let config = FriggConfig::from_workspace_roots(vec![root.clone()])?;
    let searcher = TextSearcher::new(config);
    let full_query = SearchTextQuery {
        query: "needle_hotspot".to_owned(),
        path_regex: None,
        limit: 10_000,
    };
    let limited_query = SearchTextQuery {
        query: "needle_hotspot".to_owned(),
        path_regex: None,
        limit: LIMIT,
    };

    let full = searcher.search_literal_with_filters(full_query, SearchFilters::default())?;
    let first_limited =
        searcher.search_literal_with_filters(limited_query.clone(), SearchFilters::default())?;
    let second_limited =
        searcher.search_literal_with_filters(limited_query, SearchFilters::default())?;

    assert_eq!(first_limited.len(), LIMIT);
    assert_eq!(first_limited, second_limited);
    assert_eq!(
        first_limited,
        full.into_iter().take(LIMIT).collect::<Vec<_>>(),
        "low-limit search should stay equal to deterministic sorted prefix on large corpus"
    );

    cleanup_workspace(&root);
    Ok(())
}
