// Integration-test exemption (same posture as the other tidal-server tests):
// unwrap on known-good fixtures is idiomatic here.
#![allow(clippy::unwrap_used)]
//! End-to-end integration test for the standalone data path through the
//! blocking-work offload (`crate::offload::offload_read`).
//!
//! CRITICAL 8 moved the standalone `feed`/`search` engine calls onto the tokio
//! blocking pool. This test drives the real handlers in-process via
//! `tower::ServiceExt::oneshot` (no TCP bind) — POST /items + /signals, then GET
//! /feed — and asserts a ranked result comes back, proving the offload path is
//! wired correctly and returns the same answers as an inline call would.

use std::sync::Arc;

use axum::{
    body::Body,
    http::{Method, Request, StatusCode},
};
use tidal_server::{router::build_router, state::ServerState};
use tidaldb::TidalDb;
use tower::ServiceExt;

fn make_app() -> axum::Router {
    // Default schema + built-in profiles (incl. `for_you`), same setup as
    // `run_standalone` in main.rs. No API key so requests need no auth.
    let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .with_profiles(profiles)
        .open()
        .unwrap();
    let state = Arc::new(ServerState::new(db));
    build_router(state, None)
}

async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::POST)
                .uri(uri)
                .header("Content-Type", "application/json")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    response.status()
}

async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri(uri)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
    (status, json)
}

/// POST /items + /signals then GET /feed returns ranked items via the offload
/// path. Proves the blocking engine call runs on the blocking pool and produces
/// a correct ranked result end to end.
#[tokio::test]
async fn feed_returns_ranked_items_through_offload() {
    let app = make_app();

    // Write three items with a created_at so the `for_you` Hot sort has an age.
    for id in 1u64..=3 {
        let status = post_json(
            &app,
            "/items",
            serde_json::json!({
                "entity_id": id,
                "metadata": { "created_at": "1700000000", "category": "tech" }
            }),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "item {id} create");
    }

    // Drive view signals so the offloaded scoring path has something to rank.
    // Item 3 gets the most weight, so it should out-rank the rest.
    for (id, weight) in [(1u64, 1.0), (2, 2.0), (3, 5.0)] {
        let status = post_json(
            &app,
            "/signals",
            serde_json::json!({
                "entity_id": id,
                "signal": "view",
                "weight": weight
            }),
        )
        .await;
        assert_eq!(status, StatusCode::NO_CONTENT, "signal for item {id}");
    }

    // GET /feed — this is the handler that now offloads `retrieve` to the
    // blocking pool. Assert it returns a ranked, non-empty result set.
    let (status, body) = get_json(&app, "/feed?profile=for_you&limit=10").await;
    assert_eq!(status, StatusCode::OK, "feed body: {body}");

    let items = body
        .get("items")
        .and_then(serde_json::Value::as_array)
        .expect("feed response has an items array");
    assert!(
        !items.is_empty(),
        "feed should rank the written items: {body}"
    );

    // Ranks must be contiguous starting at 1 (the engine's rank is 1-based) — a
    // real ordered result, not a degenerate empty/echo response.
    for (idx, item) in items.iter().enumerate() {
        let rank = item
            .get("rank")
            .and_then(serde_json::Value::as_u64)
            .unwrap();
        assert_eq!(rank as usize, idx + 1, "ranks contiguous from 1");
        assert!(
            item.get("score")
                .and_then(serde_json::Value::as_f64)
                .is_some(),
            "each item carries a score"
        );
    }

    let total = body
        .get("total_candidates")
        .and_then(serde_json::Value::as_u64)
        .expect("total_candidates present");
    assert!(total >= 3, "all three items were candidates: {body}");
}

/// GET /feed succeeds even with no data — the offload path must not hang or 500
/// on an empty engine; it returns an empty ranked result.
#[tokio::test]
async fn empty_feed_succeeds_through_offload() {
    let app = make_app();
    let (status, body) = get_json(&app, "/feed?profile=for_you&limit=10").await;
    assert_eq!(status, StatusCode::OK, "feed body: {body}");
    let items = body
        .get("items")
        .and_then(serde_json::Value::as_array)
        .expect("items array present");
    assert!(items.is_empty(), "no items written, feed is empty");
}
