// Integration-test exemption (same posture as the other tidal-server tests):
// unwrap on known-good fixtures + rank index casts are idiomatic here.
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
//! End-to-end integration coverage for the STANDALONE data surface.
//!
//! `middleware.rs` covers auth / body-limit / request-ID and
//! `standalone_offload.rs` covers the `/feed` offload round trip, but neither
//! drives the full data path through the HTTP surface nor pins the
//! engine-result → DTO mapping. This test:
//!
//! 1. Drives the real handlers in-process via `tower::ServiceExt::oneshot`
//!    (no TCP bind): POST /items → POST /embeddings → POST /signals → GET /feed
//!    → GET /search.
//! 2. Asserts the [`crate::dto`] shape: `signals` is OMITTED when empty and
//!    PRESENT when a profile populates the snapshot; search items carry
//!    `bm25_score`.
//! 3. Asserts the standalone region-rejection path: any `?region=` param is a
//!    400 in both `/feed` and `/search` (region routing is a cluster concept).

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`/`trending`), 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 {
    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()
        .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)
}

/// Full standalone round trip: items + embeddings + signals, then a ranked
/// /feed and a /search over the indexed titles. Pins the DTO shape in both
/// directions.
#[tokio::test]
async fn items_signals_feed_search_round_trip() {
    let app = make_app();

    // Two items with searchable titles + a created_at for the Hot sort age.
    for (id, title) in [(1u64, "jazz piano nocturne"), (2u64, "ambient jazz drift")] {
        let status = post_json(
            &app,
            "/items",
            serde_json::json!({
                "entity_id": id,
                "metadata": { "created_at": "1700000000", "title": title, "category": "music" }
            }),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "item {id} create");

        let status = post_json(
            &app,
            "/embeddings",
            serde_json::json!({ "entity_id": id, "values": vec![0.1f32; 128] }),
        )
        .await;
        assert_eq!(status, StatusCode::NO_CONTENT, "item {id} embedding");
    }

    // Drive view signals so the ranking profile has a signal snapshot to expose.
    // Item 2 gets more weight so it should out-rank item 1.
    for (id, weight) in [(1u64, 1.0), (2u64, 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 ────────────────────────────────────────────────────────────
    // `for_you` boosts the `view` signal, so the feed items must carry a
    // populated `signals` array (the DTO present-when-populated branch).
    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 has items array");
    assert!(
        !items.is_empty(),
        "feed should rank the written items: {body}"
    );

    // Ranks contiguous from 1; the heavier-weighted item 2 ranks first.
    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"
        );
    }
    assert_eq!(
        items[0]
            .get("entity_id")
            .and_then(serde_json::Value::as_u64),
        Some(2),
        "heavier-weighted item ranks first: {body}"
    );

    // DTO: at least one ranked item must expose a populated `signals` array
    // (the `for_you` view boost), proving the present-when-populated branch.
    let any_with_signals = items.iter().any(|item| {
        item.get("signals")
            .and_then(serde_json::Value::as_array)
            .is_some_and(|s| !s.is_empty())
    });
    assert!(
        any_with_signals,
        "a boosted profile must populate the signals snapshot: {body}"
    );

    // ── GET /search ──────────────────────────────────────────────────────────
    // The background text syncer commits on a ~2s cadence (ephemeral index,
    // manual reload), and the `/search` handler reloads the reader on every
    // call. Poll the real endpoint until the committed docs are visible — a
    // deterministic end-to-end wait on the real syncer, not a fixed sleep.
    let body = poll_search_until_nonempty(&app, "/search?query=jazz&limit=10").await;
    let results = body
        .get("items")
        .and_then(serde_json::Value::as_array)
        .expect("search has items array");
    assert!(
        !results.is_empty(),
        "search should match the jazz titles within the syncer window: {body}"
    );
    for (idx, item) in results.iter().enumerate() {
        let rank = item
            .get("rank")
            .and_then(serde_json::Value::as_u64)
            .unwrap();
        assert_eq!(rank as usize, idx + 1, "search ranks contiguous from 1");
    }
    // A pure-text query must surface a BM25 component in the DTO for the
    // matched items (the `skip_serializing_if = Option::is_none` field is
    // present when scored).
    let any_bm25 = results.iter().any(|item| {
        item.get("bm25_score")
            .and_then(serde_json::Value::as_f64)
            .is_some()
    });
    assert!(any_bm25, "text search must expose bm25_score: {body}");
}

/// Re-issue a `/search` against the real handler until it returns a non-empty
/// `items` array or a bounded deadline elapses. Each call asserts 200 and
/// reloads the text reader (the handler does), so this waits on the real
/// background syncer commit (≈2s cadence) without a blind fixed sleep.
async fn poll_search_until_nonempty(app: &axum::Router, uri: &str) -> serde_json::Value {
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
    loop {
        let (status, body) = get_json(app, uri).await;
        assert_eq!(status, StatusCode::OK, "search body: {body}");
        let nonempty = body
            .get("items")
            .and_then(serde_json::Value::as_array)
            .is_some_and(|a| !a.is_empty());
        if nonempty || std::time::Instant::now() >= deadline {
            return body;
        }
        // The text syncer runs on its own OS thread, so a brief blocking sleep
        // here (between awaits, not during one) does not stall the commit — it
        // just paces the retry. `tokio::time` is not enabled for this crate, so
        // a std sleep is the right tool.
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
}

/// DTO: an item with NO signals must serialize with `signals` ABSENT (the
/// skip-when-empty branch). Uses the `new` profile (sort-by-recency, no boosts),
/// so no signal snapshot is attached even though the item exists.
#[tokio::test]
async fn feed_omits_signals_when_empty() {
    let app = make_app();

    let status = post_json(
        &app,
        "/items",
        serde_json::json!({
            "entity_id": 42,
            "metadata": { "created_at": "1700000000", "title": "no signals here" }
        }),
    )
    .await;
    assert_eq!(status, StatusCode::CREATED);

    // `new` is a built-in sort-by-recency profile with no signal boosts, so the
    // ranked item carries an empty snapshot → `signals` omitted from the JSON.
    let (status, body) = get_json(&app, "/feed?profile=new&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 has items array");
    assert!(!items.is_empty(), "the item should still rank: {body}");
    for item in items {
        assert!(
            item.get("signals").is_none(),
            "empty signal snapshot must be OMITTED, not [] : {item}"
        );
    }
}

/// Standalone region rejection: any `?region=` param is a 400 on `/feed`.
/// Region routing is a cluster-only concept; a standalone server must reject it
/// loudly rather than silently ignore it.
#[tokio::test]
async fn feed_rejects_region_param() {
    let app = make_app();
    let (status, body) = get_json(&app, "/feed?profile=for_you&region=us-east").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
    let err = body
        .get("error")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");
    assert!(
        err.contains("region routing requires cluster mode"),
        "error must explain region routing is cluster-only: {body}"
    );
}

/// Standalone region rejection also applies to `/search`.
#[tokio::test]
async fn search_rejects_region_param() {
    let app = make_app();
    let (status, body) = get_json(&app, "/search?query=jazz&region=us-east").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
    let err = body
        .get("error")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");
    assert!(
        err.contains("region routing requires cluster mode"),
        "error must explain region routing is cluster-only: {body}"
    );
}

/// The `OpenAPI` document is served, unauthenticated, at `GET /openapi.json`
/// and describes the data surface (this is the canonical HTTP API reference).
/// The served document — not just the in-crate `StandaloneApiDoc` unit test — is
/// the contract clients fetch, so pin it end-to-end through the router.
#[tokio::test]
async fn openapi_json_is_served_and_describes_the_data_routes() {
    let app = make_app();
    let (status, body) = get_json(&app, "/openapi.json").await;
    assert_eq!(status, StatusCode::OK, "body: {body}");

    // OpenAPI 3.x envelope: a top-level `openapi` version string.
    let version = body
        .get("openapi")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");
    assert!(
        version.starts_with("3."),
        "expected an OpenAPI 3.x document, got openapi={version:?}"
    );

    // The /feed path must be advertised so generated clients can call the feed.
    assert!(
        body.pointer("/paths/~1feed").is_some(),
        "served spec must document the /feed path: {body}"
    );

    // The bearerAuth scheme must be registered so clients know the data routes
    // are token-gated when TIDAL_API_KEY is set.
    assert!(
        body.pointer("/components/securitySchemes/bearerAuth")
            .is_some(),
        "served spec must declare the bearerAuth security scheme: {body}"
    );
}
