//! Live end-to-end tests for `GeminiClient` against real
//! gemini.google.com. No Chrome, no DOM — just HTTP with cookies.
//!
//! ## Requirements
//!
//! - A Netscape-format cookie export from a signed-in gemini.google.com
//!   session. Default location `~/.tail-fin/gemini-cookies.txt`; override
//!   via `GEMINI_COOKIES=/path/to/cookies.txt`.
//! - For image tests: set `GEMINI_TEST_IMAGE=/path/to/small.jpg` (any
//!   <2 MB image works). Image tests are skipped when the env var is
//!   unset.
//!
//! All tests are `#[ignore]`-gated so plain `cargo test` is fast and
//! offline. Run the full live suite with:
//!
//! ```sh
//! cargo test -p tail-fin-gemini --test gemini_live -- --ignored --nocapture
//! ```
//!
//! Parallel execution is fine — unlike browser-based tests, these hit
//! HTTP only.

use std::path::PathBuf;

use tail_fin_gemini::GeminiClient;

/// Resolve the cookie file: `GEMINI_COOKIES` env var, else the default
/// `~/.tail-fin/gemini-cookies.txt`. Returns `None` (causing the caller
/// to `return` early with a SKIP message) when the file is absent — so
/// `cargo test --ignored` on a machine without credentials is a no-op
/// rather than a failure.
fn cookies_path() -> Option<PathBuf> {
    if let Ok(p) = std::env::var("GEMINI_COOKIES") {
        let path = PathBuf::from(p);
        if path.exists() {
            return Some(path);
        }
        eprintln!("SKIP: GEMINI_COOKIES points to missing file");
        return None;
    }
    let home = std::env::var("HOME").ok()?;
    let default = PathBuf::from(home).join(".tail-fin/gemini-cookies.txt");
    if default.exists() {
        Some(default)
    } else {
        eprintln!(
            "SKIP: no cookies at {} (set GEMINI_COOKIES=<path> or export from a browser)",
            default.display()
        );
        None
    }
}

fn client() -> Option<GeminiClient> {
    let path = cookies_path()?;
    match GeminiClient::from_cookie_file(&path) {
        Ok(c) => Some(c),
        Err(e) => {
            eprintln!("SKIP: failed to load cookies: {e}");
            None
        }
    }
}

/// Fetch a client tagged with the test name, so `--nocapture` output
/// is unambiguous about which tests actually ran.
fn client_or_skip(test_name: &str) -> Option<GeminiClient> {
    let c = client()?;
    eprintln!("[{test_name}] running against live Gemini");
    Some(c)
}

/// True iff `reply`, with leading whitespace/punctuation stripped,
/// starts with `word` followed by a non-letter (or end). Catches the
/// naive `contains(&reply, "no")` false positives like "do NOt" /
/// "Nothing" / "Not sure".
fn starts_with_word_ci(reply: &str, word: &str) -> bool {
    let trimmed = reply.trim_start_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
    let word_lower = word.to_lowercase();
    let head: String = trimmed.chars().take(word_lower.chars().count()).collect();
    if head.to_lowercase() != word_lower {
        return false;
    }
    // The char right after must be non-alphabetic (or end-of-string).
    trimmed[head.len()..]
        .chars()
        .next()
        .is_none_or(|c| !c.is_alphabetic())
}

#[test]
fn starts_with_word_ci_matches_leading_word() {
    assert!(starts_with_word_ci("Yes, I saw a forest.", "yes"));
    assert!(starts_with_word_ci("  No.", "no"));
    assert!(starts_with_word_ci("**Yes**", "yes"));
}

#[test]
fn starts_with_word_ci_rejects_embedded() {
    assert!(!starts_with_word_ci("Nothing natural appeared.", "no"));
    assert!(!starts_with_word_ci("I do not see an image.", "no"));
    assert!(!starts_with_word_ci("yesterday was cloudy", "yes"));
}

fn image_path() -> Option<PathBuf> {
    let p = std::env::var("GEMINI_TEST_IMAGE").ok()?;
    let path = PathBuf::from(p);
    if path.exists() {
        Some(path)
    } else {
        eprintln!("SKIP: GEMINI_TEST_IMAGE={} does not exist", path.display());
        None
    }
}

/// Case-insensitive substring match — model replies vary in capitalisation
/// and punctuation ("Mochi", "mochi", "**Mochi**") so we relax that.
fn contains_ci(haystack: &str, needle: &str) -> bool {
    haystack.to_lowercase().contains(&needle.to_lowercase())
}

// ---------------------------------------------------------------------------
// 1. Baseline: text-only ask returns a non-empty reply with ids.
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn live_text_ask_returns_reply_and_ids() {
    let Some(client) = client_or_skip("live_text_ask_returns_reply_and_ids") else {
        return;
    };
    let resp = client
        .ask("Reply with exactly the word PONG and nothing else.", None)
        .await
        .expect("ask failed");

    assert!(!resp.response.is_empty(), "empty response");
    assert!(
        contains_ci(&resp.response, "pong"),
        "expected 'PONG' in response; got: {:?}",
        resp.response
    );
    assert!(
        resp.conversation_id
            .as_deref()
            .unwrap_or("")
            .starts_with("c_"),
        "conversation_id missing/malformed: {:?}",
        resp.conversation_id
    );
    assert!(
        resp.response_id.as_deref().unwrap_or("").starts_with("r_"),
        "response_id missing/malformed: {:?}",
        resp.response_id
    );
    assert!(
        resp.choice_id.as_deref().unwrap_or("").starts_with("rc_"),
        "choice_id missing/malformed: {:?}",
        resp.choice_id
    );
    assert!(resp.can_continue());
}

// ---------------------------------------------------------------------------
// 2. Multi-turn: fact established in turn 1 must be recallable in turn 2.
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn live_multi_turn_recalls_fact() {
    let Some(client) = client_or_skip("live_multi_turn_recalls_fact") else {
        return;
    };

    let t1 = client
        .ask(
            "Fact: my cat Mochi is 3 years old. Reply with just 'Noted.'",
            None,
        )
        .await
        .expect("turn 1 failed");
    assert!(t1.can_continue(), "turn 1 missing ids: {t1:?}");

    let t2 = client
        .ask_continue(
            "What is my cat's name? Reply with just the name.",
            None,
            &t1,
        )
        .await
        .expect("turn 2 failed");

    assert_eq!(
        t2.conversation_id, t1.conversation_id,
        "conversation_id should be stable across turns"
    );
    assert!(
        contains_ci(&t2.response, "mochi"),
        "turn 2 should recall 'Mochi' from turn 1; got: {:?}",
        t2.response
    );

    // Turn 3: chain off turn 2's fresh rid/rcid; recall the age.
    let t3 = client
        .ask_continue("How old is she? Reply with just the number.", None, &t2)
        .await
        .expect("turn 3 failed");

    assert_eq!(t3.conversation_id, t1.conversation_id);
    assert!(
        t3.response.contains('3'),
        "turn 3 should recall '3' from turn 1; got: {:?}",
        t3.response
    );
}

// ---------------------------------------------------------------------------
// 3. Image attachment on first turn — Gemini describes the image.
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn live_image_attachment() {
    let Some(client) = client_or_skip("live_image_attachment") else {
        return;
    };
    let Some(img) = image_path() else { return };

    let resp = client
        .ask("Describe this image in one short sentence.", Some(&img))
        .await
        .expect("image ask failed");

    assert!(!resp.response.is_empty(), "empty response");
    // Minimal assertion: response shouldn't echo the prompt verbatim
    // and should be more than a stub.
    assert!(
        resp.response.len() > 15,
        "response too short to be a real description: {:?}",
        resp.response
    );
    assert!(resp.can_continue());
}

// ---------------------------------------------------------------------------
// 4. Image attachment on a *follow-up* turn — the hardest case.
//    Turn 1 sets up context (text), turn 2 attaches image + chains ids.
//    Turn 3 (text only) must still recall the image Gemini saw in turn 2.
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn live_multi_turn_image_on_followup() {
    let Some(client) = client_or_skip("live_multi_turn_image_on_followup") else {
        return;
    };
    let Some(img) = image_path() else { return };

    let t1 = client
        .ask(
            "I'm going to show you an image next. Reply with just 'Ready.'",
            None,
        )
        .await
        .expect("turn 1 failed");
    assert!(t1.can_continue());

    let t2 = client
        .ask_continue("Here it is. What is in one word?", Some(&img), &t1)
        .await
        .expect("turn 2 (image follow-up) failed");
    assert!(!t2.response.is_empty(), "turn 2 empty");
    assert_eq!(t2.conversation_id, t1.conversation_id);

    // Turn 3: no image, but Gemini should still reference what it saw.
    let t3 = client
        .ask_continue(
            "Was there a natural scene in the image I just showed you? \
             Reply in English with the single word 'Yes' or 'No' only, \
             no punctuation, no other language.",
            None,
            &t2,
        )
        .await
        .expect("turn 3 failed");
    assert_eq!(t3.conversation_id, t1.conversation_id);
    // Strict: reply must START with Yes or No as a word. Naive
    // `contains("no")` would falsely accept "I do NOt see an image" /
    // "NOthing natural appeared".
    assert!(
        starts_with_word_ci(&t3.response, "yes") || starts_with_word_ci(&t3.response, "no"),
        "turn 3 should lead with Yes/No about the image; got: {:?}",
        t3.response
    );
}

// Negative paths (partial-continuation rejection, literal "null"
// sanitisation, prefix validation) are covered exhaustively by unit
// tests in `types.rs`, `client.rs`, and `cli/gemini.rs`. No live
// integration test is needed here: those paths short-circuit before
// any network round-trip, so the coverage is deterministic and
// doesn't benefit from going through HTTP.
