//! p9-fb-34: App::search_with_opts integration tests.

mod common;

use kebab_app::SearchResponse;
use kebab_core::{SearchFilters, SearchMode, SearchOpts, SearchQuery};

fn lex(text: &str, k: usize) -> SearchQuery {
    SearchQuery {
        text: text.to_string(),
        mode: SearchMode::Lexical,
        k,
        filters: SearchFilters::default(),
    }
}

#[test]
fn search_with_opts_no_budget_matches_search() {
    let env = common::TestEnv::new();
    common::ingest_md(&env, "a.md", "# T\n\napples are red\n");
    let app = env.app();

    let baseline = app.search(lex("apples", 5)).unwrap();
    let resp: SearchResponse = app
        .search_with_opts(lex("apples", 5), SearchOpts::default())
        .unwrap();

    assert_eq!(resp.hits.len(), baseline.len());
    assert!(!resp.truncated);
    assert!(
        resp.next_cursor.is_none(),
        "k=5 against 1 doc → no next page"
    );
}

#[test]
fn budget_truncates_snippets_when_below_threshold() {
    let env = common::TestEnv::new();
    let body: String = "rust ownership is a memory model. ".repeat(10);
    common::ingest_md(&env, "a.md", &format!("# T\n\n{body}\n"));
    let app = env.app();

    let unrestricted = app.search(lex("rust", 5)).unwrap();
    let unrestricted_chars: usize = unrestricted.iter().map(|h| h.snippet.chars().count()).sum();

    let resp = app
        .search_with_opts(
            lex("rust", 5),
            SearchOpts {
                max_tokens: Some(50),
                snippet_chars: None,
                cursor: None,
                trace: false,
            },
        )
        .unwrap();
    let limited_chars: usize = resp.hits.iter().map(|h| h.snippet.chars().count()).sum();

    assert!(resp.truncated, "small budget must trip truncation");
    assert!(limited_chars < unrestricted_chars, "snippet should shrink");
    assert!(!resp.hits.is_empty(), "always retain ≥1 hit");
}

#[test]
fn cursor_paginates_to_next_page() {
    let env = common::TestEnv::new();
    for i in 0..6 {
        common::ingest_md(
            &env,
            &format!("d{i}.md"),
            &format!("# T{i}\n\nrust topic {i}\n"),
        );
    }
    let app = env.app();

    let page1 = app
        .search_with_opts(lex("rust", 2), SearchOpts::default())
        .unwrap();
    assert_eq!(page1.hits.len(), 2);
    let cursor = page1.next_cursor.expect("more hits available");

    let page2 = app
        .search_with_opts(
            lex("rust", 2),
            SearchOpts {
                max_tokens: None,
                snippet_chars: None,
                cursor: Some(cursor),
                trace: false,
            },
        )
        .unwrap();
    assert_eq!(page2.hits.len(), 2);
    let p1_ids: std::collections::HashSet<_> =
        page1.hits.iter().map(|h| h.chunk_id.0.clone()).collect();
    let p2_ids: std::collections::HashSet<_> =
        page2.hits.iter().map(|h| h.chunk_id.0.clone()).collect();
    assert!(
        p1_ids.is_disjoint(&p2_ids),
        "page 2 must not repeat page 1 hits"
    );
}

#[test]
fn cursor_rejected_after_corpus_revision_bump() {
    let env = common::TestEnv::new();
    common::ingest_md(&env, "a.md", "# T\n\napples\n");
    let app = env.app();

    let page1 = app
        .search_with_opts(lex("apples", 1), SearchOpts::default())
        .unwrap();
    // p9-fb-34 round-1 review: replaced silent `if let Some(c) = ...`
    // with `.expect(...)` so a fixture regression that breaks the
    // cursor-emission contract fails loudly instead of passing vacuously.
    let c = page1
        .next_cursor
        .expect("k=1 page must emit next_cursor — fixture too small if this fails");

    common::ingest_md(&env, "b.md", "# B\n\nbananas\n");
    let app2 = env.app();

    let result = app2.search_with_opts(
        lex("apples", 1),
        SearchOpts {
            max_tokens: None,
            snippet_chars: None,
            cursor: Some(c),
            trace: false,
        },
    );
    let err = result.unwrap_err();
    assert!(
        err.to_string().contains("stale_cursor"),
        "must surface stale_cursor: {err}"
    );
}

#[test]
fn max_tokens_zero_returns_one_hit_truncated() {
    // p9-fb-34 round-1 review: pin the documented "≥1 hit floor"
    // contract — even with `max_tokens=0` (an absurdly tight budget)
    // the budget loop must keep one hit and flip `truncated: true`.
    // Fixture intentionally seeds multiple matches so step 2 of the
    // budget loop (pop hits to 1) actually fires.
    let env = common::TestEnv::new();
    for i in 0..3 {
        common::ingest_md(
            &env,
            &format!("d{i}.md"),
            &format!("# T{i}\n\napples are red {i}\n"),
        );
    }
    let app = env.app();

    let resp = app
        .search_with_opts(
            lex("apples", 5),
            SearchOpts {
                max_tokens: Some(0),
                snippet_chars: None,
                cursor: None,
                trace: false,
            },
        )
        .unwrap();
    assert_eq!(resp.hits.len(), 1, "max_tokens=0 collapses to 1-hit floor");
    assert!(resp.truncated);
    // p9-fb-34 R2: cursor IS emitted on k-pop case so the popped
    // hits remain reachable.
    assert!(
        resp.next_cursor.is_some(),
        "k-pop truncation must still emit next_cursor; popped hits at offset+returned"
    );
}
