//! Live end-to-end tests for `tail-fin-reddit` against `www.reddit.com`.
//!
//! Reddit's `.json` endpoints are anonymous — no login, no cookies — but
//! the adapter routes everything through `page_fetch`, which is a
//! same-origin `fetch()` from inside an active `BrowserSession`. So
//! every test here launches a fresh stealth Chrome (no cookies injected)
//! and lets `ensure_on_domain` auto-navigate to www.reddit.com before
//! the API call.
//!
//! All tests are `#[ignore]`-gated.
//!
//! Run (must be serial — Chrome user-data-dir is a singleton):
//!   cargo test -p tail-fin-reddit --test reddit_live -- \
//!       --ignored --test-threads=1 --nocapture
//!
//! On macOS (M1) Chrome also needs libiconv on the link path:
//!   RUSTFLAGS="-L /opt/homebrew/Cellar/libiconv/1.19/lib" \
//!       cargo test -p tail-fin-reddit --test reddit_live -- \
//!       --ignored --test-threads=1
//!
//! Pinned-fixture data:
//! - subreddit `r/rust` — long-tenured, won't disappear, posts always present
//! - user `spez` — Reddit CEO, account exists since 2005, karma always > 0
//! - search query `"rust programming"` — broad enough to always have hits

use tail_fin_common::BrowserSession;
use tail_fin_reddit::RedditClient;

async fn fresh_session() -> BrowserSession {
    BrowserSession::builder()
        .headed(false)
        .viewport(1440, 900)
        .build()
        .await
        .expect("browser launch")
}

fn assert_post_shape(post: &tail_fin_reddit::RedditPost) {
    assert!(!post.id.is_empty(), "post id empty: {post:?}");
    assert!(!post.title.is_empty(), "post title empty: {post:?}");
    assert!(!post.subreddit.is_empty(), "post subreddit empty: {post:?}");
    // Reddit author is `[deleted]` for removed users; non-empty either way.
    assert!(!post.author.is_empty(), "post author empty: {post:?}");
    // Score can be negative (downvoted into oblivion); just assert the
    // field round-tripped as i64 (it did, by construction). url should
    // be a real URL (http://, https://, or an internal r/.../comments/
    // permalink). Use a strict scheme check so a parser that loses the
    // `s` from `https` or grabs the wrong field can't pass.
    assert!(
        post.url.starts_with("http://")
            || post.url.starts_with("https://")
            || post.url.starts_with("/r/")
            || post.url.starts_with("/comments/"),
        "post url unexpected: {:?}",
        post.url
    );
}

// ---------------------------------------------------------------------------
// Anonymous listing endpoints
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn hot_returns_listing_with_after_token() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);

    let listing = client.hot(5, None).await.expect("hot");
    assert!(
        !listing.posts.is_empty(),
        "hot returned 0 posts; Reddit may have changed format"
    );
    assert!(
        listing.posts.len() <= 5,
        "limit not honored: got {}",
        listing.posts.len()
    );
    // `after` is a pagination cursor, populated whenever there are more
    // pages (which there always are for hot). If this is None, the
    // listing parser likely lost the field.
    assert!(
        listing.after.is_some(),
        "expected pagination cursor, got None"
    );
    for post in &listing.posts {
        assert_post_shape(post);
    }
}

#[tokio::test]
#[ignore]
async fn popular_returns_listing() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);
    let listing = client.popular(3, None).await.expect("popular");
    assert!(!listing.posts.is_empty());
    assert!(listing.posts.len() <= 3);
    for post in &listing.posts {
        assert_post_shape(post);
    }
}

#[tokio::test]
#[ignore]
async fn subreddit_rust_returns_rust_posts() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);
    let listing = client.subreddit("rust", 3, None).await.expect("subreddit");
    assert!(!listing.posts.is_empty());
    // Every post returned must come FROM the requested subreddit.
    // Reddit's case-insensitive in path but echoes back the canonical
    // capitalization (`rust`) in the JSON.
    for post in &listing.posts {
        assert_post_shape(post);
        assert!(
            post.subreddit.eq_ignore_ascii_case("rust"),
            "subreddit filter leaked, got post in r/{}: {:?}",
            post.subreddit,
            post.title
        );
    }
}

#[tokio::test]
#[ignore]
async fn search_rust_programming_finds_results() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);
    let listing = client
        .search("rust programming", 5, None)
        .await
        .expect("search");
    assert!(
        !listing.posts.is_empty(),
        "search for 'rust programming' returned 0 results"
    );
    for post in &listing.posts {
        assert_post_shape(post);
    }
    // At least one result should mention "rust" somewhere — title or
    // selftext. If none does, the search relevance is broken or our
    // query parser is dropping spaces.
    let any_rusty = listing.posts.iter().any(|p| {
        p.title.to_lowercase().contains("rust")
            || p.selftext.to_lowercase().contains("rust")
            || p.subreddit.eq_ignore_ascii_case("rust")
    });
    assert!(
        any_rusty,
        "no 'rust' mention in any of {} search results — relevance broken?",
        listing.posts.len()
    );
}

// ---------------------------------------------------------------------------
// Profile + thread parsing
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn user_spez_has_karma() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);
    let user = client.user("spez").await.expect("user spez");
    assert_eq!(user.name, "spez");
    assert!(
        user.karma > 1_000,
        "spez (Reddit CEO since 2005) should have karma > 1_000, got {}",
        user.karma
    );
    assert!(
        !user.created_at.is_empty(),
        "user created_at empty: {user:?}"
    );
}

/// Chains `hot` → `read`: takes the first post ID returned by `hot` and
/// reads its thread. Tests both endpoints' wire compatibility — the
/// `id` returned by `parse_listing` must be a valid argument to `read`.
#[tokio::test]
#[ignore]
async fn read_thread_from_hot_returns_post_and_comments() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);

    // Pull a wider hot listing (15 posts) than the strict-shape test
    // needs — the extra headroom protects against the unlikely day
    // where every top-N hot post has 0 comments.
    let listing = client.hot(15, None).await.expect("hot");
    let with_comments = listing
        .posts
        .iter()
        .find(|p| p.num_comments > 0)
        .expect("expected at least one hot post with comments");
    let post_id = with_comments.id.clone();

    let thread = client
        .read(&post_id, 2)
        .await
        .expect("read thread for a fresh hot-list ID");

    // The thread's post field must reference the SAME id we asked for —
    // catches a regression where `parse_thread` would silently return
    // some adjacent post.
    assert_eq!(thread.post.id, post_id);
    assert_post_shape(&thread.post);
    // We picked a post with num_comments > 0, so the comments vec must
    // be non-empty. Each comment carries a body and an id at minimum.
    assert!(
        !thread.comments.is_empty(),
        "post claims {} comments but parse returned 0",
        with_comments.num_comments
    );
    for c in &thread.comments {
        assert!(!c.id.is_empty(), "comment missing id: {c:?}");
        // Comment body can be `[deleted]` / `[removed]`; non-empty either way.
        assert!(!c.body.is_empty(), "comment body empty: {c:?}");
    }
}

// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn pagination_after_token_advances_listing() {
    let session = fresh_session().await;
    let client = RedditClient::new(session);

    let page1 = client.hot(3, None).await.expect("hot page 1");
    assert!(!page1.posts.is_empty());
    let after = page1
        .after
        .clone()
        .expect("page 1 must yield a pagination cursor");

    let page2 = client.hot(3, Some(&after)).await.expect("hot page 2");
    assert!(!page2.posts.is_empty(), "page 2 returned 0 posts");

    // Strong invariant: the `after` cursor must advance between pages.
    // This survives hot-list churn — page 2's posts could differ from
    // page 1's just because new posts voted up, even if `after=` were
    // silently dropped — but the cursor itself only changes when the
    // server actually paged forward.
    assert_ne!(
        page1.after, page2.after,
        "pagination cursor did not advance; `after=` likely not wired through"
    );

    // Weaker but human-readable: page 2 should also expose at least
    // one post that wasn't in page 1.
    let page1_ids: std::collections::HashSet<_> =
        page1.posts.iter().map(|p| p.id.as_str()).collect();
    let any_new = page2
        .posts
        .iter()
        .any(|p| !page1_ids.contains(p.id.as_str()));
    assert!(
        any_new,
        "page 2 is identical to page 1 — `after=` cursor not wired through"
    );
}
