//! Tests for the appview read layer.
//!
//! The read helpers are exercised against an in-memory SQLite database with the
//! migrations applied and representative rows seeded directly (the projection is
//! tested separately in `indexing::projection`). The bsky profile fetch is never
//! hit here: the polymodel union variant comes from seeded SQLite, and the bsky
//! variant's union discrimination is covered by a serde round-trip.

#![cfg(test)]

use axum::body::Body;
use axum::http::{Request, StatusCode};
use jacquard_common::types::string::Did;
use polymodel_api::space_polymodel::actor::ProfileViewUnion;
use serde_json::json;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::collections::HashSet;
use std::str::FromStr;
use tower::ServiceExt;

use super::error::AppError;
use super::state::AppState;
use super::views;

const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa";
const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb";

fn did(s: &str) -> Did {
    Did::new_owned(s).unwrap()
}

async fn pool() -> SqlitePool {
    let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
    let pool = SqlitePoolOptions::new()
        .max_connections(1)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

async fn state() -> AppState {
    let pool = pool().await;
    let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost"))
        .expect("ephemeral OAuth bootstrap for tests");
    AppState::new(pool, bootstrap)
}

// Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL.

fn blob(cid: &str) -> serde_json::Value {
    json!({
        "$type": "blob",
        "ref": { "$link": cid },
        "mimeType": "image/jpeg",
        "size": 1234
    })
}

fn image(cid: &str, alt: &str) -> serde_json::Value {
    json!({
        "alt": alt,
        "aspectRatio": { "width": 4, "height": 3 },
        "image": blob(cid)
    })
}

fn strong_ref(uri: &str) -> serde_json::Value {
    json!({ "uri": uri, "cid": "bafyreicid" })
}

async fn seed_thing(
    pool: &SqlitePool,
    did: &str,
    rkey: &str,
    name: &str,
    models: &[&str],
    like_count: i64,
) -> String {
    let uri = format!("at://{did}/space.polymodel.library.thing/{rkey}");
    let now = chrono::Utc::now()
        .timestamp_nanos_opt()
        .expect("current timestamp must fit in i64 nanoseconds");
    let created = now - 86_400_000_000_000; // 1 day ago by default
    sqlx::query(
        r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,
           tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)
           VALUES (?, ?, ?, ?, ?, NULL, 'CC-BY-4.0', NULL, NULL, NULL, NULL, NULL, ?, ?, ?)"#,
    )
    .bind(did)
    .bind(rkey)
    .bind(&uri)
    .bind("cid")
    .bind(name)
    .bind(created)
    .bind(now)
    .bind(json!({
        "name": name,
        "license": "CC-BY-4.0",
        "cover": [image("bafkreiathingcover", "Thing cover")],
        "previews": [image("bafkreiathingpreview", "Thing preview")],
        "models": models.iter().map(|m| strong_ref(m)).collect::<Vec<_>>(),
        "createdAt": "2024-01-01T00:00:00.000Z"
    }).to_string())
    .execute(pool)
    .await
    .unwrap();
    for (pos, m) in models.iter().enumerate() {
        sqlx::query(
            "INSERT OR REPLACE INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)",
        )
        .bind(&uri)
        .bind(*m)
        .bind(pos as i64)
        .execute(pool)
        .await
        .unwrap();
    }
    sqlx::query(
        "INSERT OR IGNORE INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, 0, 0)",
    )
    .bind(&uri)
    .bind(like_count)
    .execute(pool)
    .await
    .unwrap();
    uri
}

async fn set_thing_created_at(pool: &SqlitePool, uri: &str, created_at: i64) {
    sqlx::query("UPDATE things SET created_at = ? WHERE uri = ?")
        .bind(created_at)
        .bind(uri)
        .execute(pool)
        .await
        .unwrap();
}

async fn seed_model(
    pool: &SqlitePool,
    did: &str,
    rkey: &str,
    name: &str,
    parts: &[&str],
) -> String {
    let uri = format!("at://{did}/space.polymodel.library.model/{rkey}");
    let now = chrono::Utc::now()
        .timestamp_nanos_opt()
        .expect("current timestamp must fit in i64 nanoseconds");
    sqlx::query(
        r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json)
           VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)"#,
    )
    .bind(did)
    .bind(rkey)
    .bind(&uri)
    .bind("cid")
    .bind(name)
    .bind(now)
    .bind(now)
    .bind(json!({
        "name": name,
        "cover": [image("bafkreiamodelcover", "Model cover")],
        "previews": [image("bafkreiamodelpreview", "Model preview")],
        "parts": parts.iter().map(|p| strong_ref(p)).collect::<Vec<_>>(),
        "createdAt": "2024-01-01T00:00:00.000Z"
    }).to_string())
    .execute(pool)
    .await
    .unwrap();
    for (pos, p) in parts.iter().enumerate() {
        sqlx::query(
            "INSERT OR REPLACE INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)",
        )
        .bind(&uri)
        .bind(*p)
        .bind(pos as i64)
        .execute(pool)
        .await
        .unwrap();
    }
    uri
}

async fn seed_part(pool: &SqlitePool, did: &str, rkey: &str, name: &str) -> String {
    let uri = format!("at://{did}/space.polymodel.library.part/{rkey}");
    let now = chrono::Utc::now()
        .timestamp_nanos_opt()
        .expect("current timestamp must fit in i64 nanoseconds");
    sqlx::query(
        r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json)
           VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)"#,
    )
    .bind(did)
    .bind(rkey)
    .bind(&uri)
    .bind("cid")
    .bind(name)
    .bind(json!({"mimeType": "model/stl", "size": 1, "chunks": []}).to_string())
    .bind(now)
    .bind(now)
    .bind(json!({
        "name": name,
        "file": {"mimeType": "model/stl", "size": 1, "chunks": []},
        "previews": [image("bafkreiapartpreview", "Part preview")],
        "createdAt": "2024-01-01T00:00:00.000Z"
    }).to_string())
    .execute(pool)
    .await
    .unwrap();
    uri
}

async fn seed_identity(pool: &SqlitePool, did: &str, handle: &str) {
    sqlx::query(
        "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?) \
         ON CONFLICT(did) DO UPDATE SET handle = excluded.handle",
    )
    .bind(did)
    .bind(handle)
    .bind(
        chrono::Utc::now()
            .timestamp_nanos_opt()
            .expect("current timestamp must fit in i64 nanoseconds"),
    )
    .execute(pool)
    .await
    .unwrap();
}

async fn seed_profile(pool: &SqlitePool, did: &str, display: &str) {
    let avatar = blob("bafkreiavatar").to_string();
    sqlx::query(
        r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license,
           pronouns, printers_json, links_json, record_json, indexed_at)
           VALUES (?, ?, NULL, ?, NULL, NULL, NULL, NULL, ?, ?)
           ON CONFLICT(did) DO UPDATE SET display_name = excluded.display_name,
           avatar_json = excluded.avatar_json, record_json = excluded.record_json"#,
    )
    .bind(did)
    .bind(display)
    .bind(avatar)
    .bind(json!({"displayName": display, "avatar": blob("bafkreiavatar")}).to_string())
    .bind(
        chrono::Utc::now()
            .timestamp_nanos_opt()
            .expect("current timestamp must fit in i64 nanoseconds"),
    )
    .execute(pool)
    .await
    .unwrap();
}

#[tokio::test]
async fn get_thing_hydrates_thing_with_ordered_models_and_record() {
    let state = state().await;
    let m1 = seed_model(&state.pool, DID_A, "m1", "Model One", &[]).await;
    let m2 = seed_model(&state.pool, DID_A, "m2", "Model Two", &[]).await;
    let thing = seed_thing(&state.pool, DID_A, "t1", "Cube", &[&m2, &m1], 3).await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_profile(&state.pool, DID_A, "Alice").await;

    let (view, models) = views::get_thing(&state, &thing, None).await.unwrap();
    assert_eq!(view.uri.as_ref(), thing);
    assert_eq!(view.name.as_str(), "Cube");
    assert_eq!(view.like_count, 3);
    assert_eq!(view.model_count, 2);
    assert_eq!(
        view.cover.as_ref().unwrap()[0].thumb.as_ref(),
        "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiathingcover@jpeg"
    );
    assert_eq!(
        view.previews.as_ref().unwrap()[0].fullsize.as_ref(),
        "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiathingpreview@jpeg"
    );
    assert_eq!(
        view.author.avatar.as_ref().unwrap().as_ref(),
        "https://cdn.bsky.app/img/avatar/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiavatar@jpeg"
    );
    // record must be populated from record_json (never null).
    assert!(
        serde_json::to_string(&view.record)
            .unwrap()
            .contains("Cube")
    );
    // Junction order is preserved (m2 before m1 by position).
    assert_eq!(models.len(), 2);
    assert_eq!(models[0].uri.as_ref(), m2);
    assert_eq!(models[1].uri.as_ref(), m1);
    // Author handle comes from the identity projection.
    assert_eq!(view.author.handle.as_ref(), "alice.com");
}

#[tokio::test]
async fn get_thing_missing_is_not_found() {
    let state = state().await;
    let err = views::get_thing(
        &state,
        "at://did:plc:x/space.polymodel.library.thing/none",
        None,
    )
    .await
    .unwrap_err();
    assert!(matches!(err, AppError::NotFound));
}

#[tokio::test]
async fn get_model_hydrates_parts_and_parent_thing() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    let p1 = seed_part(&state.pool, DID_A, "p1", "Part One").await;
    let p2 = seed_part(&state.pool, DID_A, "p2", "Part Two").await;
    let model = seed_model(&state.pool, DID_A, "m1", "Model", &[&p2, &p1]).await;
    let thing = seed_thing(&state.pool, DID_A, "t1", "Thing", &[&model], 0).await;

    let detail = views::get_model(&state, &model, None).await.unwrap();
    assert_eq!(detail.model.uri.as_ref(), model);
    assert_eq!(detail.model.part_count, 2);
    assert_eq!(
        detail.model.cover.as_ref().unwrap()[0].thumb.as_ref(),
        "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiamodelcover@jpeg"
    );
    assert_eq!(detail.parts.len(), 2);
    assert_eq!(detail.parts[0].uri.as_ref(), p2); // ordered by position
    assert_eq!(
        detail.parts[0].previews.as_ref().unwrap()[0].thumb.as_ref(),
        "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiapartpreview@jpeg"
    );
    assert_eq!(detail.thing.as_ref().unwrap().uri.as_ref(), thing);
    assert!(detail.thing.as_ref().unwrap().previews.is_some());
}

#[tokio::test]
async fn author_things_keyset_paginates_by_rkey() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    // rkeys are time-ordered TIDs; ordered rkeys make rkey DESC deterministic.
    seed_thing(&state.pool, DID_A, "3zzzzzzzzzzzz", "t3", &[], 0).await;
    seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await;
    seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await;

    let page1 = views::author_things(&state, DID_A, 2, None, None)
        .await
        .unwrap();
    assert_eq!(page1.items.len(), 2);
    assert_eq!(page1.items[0].thing.name.as_str(), "t3");
    let cursor = page1.cursor.expect("cursor present when more remain");

    let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref()), None)
        .await
        .unwrap();
    assert_eq!(page2.items.len(), 1);
    assert_eq!(page2.items[0].thing.name.as_str(), "t1");
    assert!(page2.cursor.is_none());
}

#[tokio::test]
async fn feed_hot_is_deterministic_with_tied_scores() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    // Two things with identical engagement and near-identical age -> tied score;
    // ordering must still be deterministic via the rkey tie-break.
    seed_thing(&state.pool, DID_A, "3aaaaaaaaaaaa", "a", &[], 5).await;
    seed_thing(&state.pool, DID_A, "3bbbbbbbbbbbb", "b", &[], 5).await;

    let feed = views::feed_hot(&state, 10, None, None).await.unwrap();
    assert_eq!(feed.items.len(), 2);
    let feed_again = views::feed_hot(&state, 10, None, None).await.unwrap();
    assert_eq!(
        feed.items[0].thing.uri.as_ref(),
        feed_again.items[0].thing.uri.as_ref()
    );
    // Tied scores break by rkey DESC.
    assert_eq!(feed.items[0].thing.name.as_str(), "b");
}

#[tokio::test]
async fn feed_hot_cursor_pins_ranking_time_and_keyset_boundary() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    let now_a = 1_700_000_000_000_000_000;
    let now_b = now_a + 90 * 86_400_000_000_000;
    let rows = [
        ("3aaaaaaaaaaaa", "fresh-low", 2, 1),
        ("3bbbbbbbbbbbb", "medium-high", 7, 12),
        ("3cccccccccccc", "older-high", 48, 100),
        ("3dddddddddddd", "newer-medium", 3, 4),
        ("3eeeeeeeeeeee", "old-low", 120, 2),
    ];
    for (rkey, name, age_hours, likes) in rows {
        let uri = seed_thing(&state.pool, DID_A, rkey, name, &[], likes).await;
        set_thing_created_at(&state.pool, &uri, now_a - age_hours * 3_600_000_000_000).await;
    }

    let expected = views::feed_hot_at(&state, 10, None, None, now_a)
        .await
        .unwrap()
        .items
        .into_iter()
        .map(|item| item.thing.uri)
        .collect::<Vec<_>>();
    assert_eq!(expected.len(), rows.len());

    let page1 = views::feed_hot_at(&state, 2, None, None, now_a)
        .await
        .unwrap();
    let cursor = page1
        .cursor
        .as_ref()
        .expect("first hot page should have a cursor");
    assert!(
        cursor.as_str().starts_with("hot:v1:"),
        "hot cursor must be versioned and opaque"
    );

    let page2 = views::feed_hot_at(&state, 10, Some(cursor.as_str()), None, now_b)
        .await
        .unwrap();
    let combined = page1
        .items
        .into_iter()
        .chain(page2.items)
        .map(|item| item.thing.uri)
        .collect::<Vec<_>>();
    let unique = combined.iter().collect::<HashSet<_>>();
    assert_eq!(
        unique.len(),
        combined.len(),
        "hot pagination duplicated an item"
    );
    assert_eq!(
        combined, expected,
        "hot pagination must keep the cursor-pinned order"
    );
}

#[tokio::test]
async fn feed_hot_cursor_boundary_handles_same_rkey_across_repos() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_identity(&state.pool, DID_B, "bob.com").await;
    let now = 1_700_000_000_000_000_000;
    let shared_rkey = "3sameeeeeeeee";
    let uri_a = seed_thing(&state.pool, DID_A, shared_rkey, "alice", &[], 5).await;
    let uri_b = seed_thing(&state.pool, DID_B, shared_rkey, "bob", &[], 5).await;
    set_thing_created_at(&state.pool, &uri_a, now - 86_400_000_000_000).await;
    set_thing_created_at(&state.pool, &uri_b, now - 86_400_000_000_000).await;

    let page1 = views::feed_hot_at(&state, 1, None, None, now)
        .await
        .unwrap();
    let cursor = page1.cursor.as_deref().expect("first row has another page");
    let page2 = views::feed_hot_at(&state, 1, Some(cursor), None, now)
        .await
        .unwrap();

    let uris = page1
        .items
        .into_iter()
        .chain(page2.items)
        .map(|item| item.thing.uri)
        .collect::<Vec<_>>();
    let unique = uris.iter().collect::<HashSet<_>>();
    assert_eq!(unique.len(), 2, "same-rkey tied rows must not duplicate");
    assert!(uris.iter().any(|uri| uri.as_ref() == uri_a));
    assert!(uris.iter().any(|uri| uri.as_ref() == uri_b));
}

#[tokio::test]
async fn feed_hot_zero_engagement_fallback_paginates_after_zero_boundary() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    let now = 1_700_000_000_000_000_000;
    for rkey in [
        "3dddddddddddd",
        "3cccccccccccc",
        "3bbbbbbbbbbbb",
        "3aaaaaaaaaaaa",
    ] {
        let uri = seed_thing(&state.pool, DID_A, rkey, rkey, &[], 0).await;
        set_thing_created_at(&state.pool, &uri, now - 86_400_000_000_000).await;
    }

    let page1 = views::feed_hot_at(&state, 2, None, None, now)
        .await
        .unwrap();
    assert_eq!(page1.items.len(), 2);
    assert_eq!(page1.items[0].thing.name.as_str(), "3dddddddddddd");
    assert_eq!(page1.items[1].thing.name.as_str(), "3cccccccccccc");
    let cursor = page1
        .cursor
        .as_deref()
        .expect("zero-score first page has cursor");

    let page2 = views::feed_hot_at(&state, 2, Some(cursor), None, now)
        .await
        .unwrap();
    assert_eq!(page2.items.len(), 2);
    assert_eq!(page2.items[0].thing.name.as_str(), "3bbbbbbbbbbbb");
    assert_eq!(page2.items[1].thing.name.as_str(), "3aaaaaaaaaaaa");
    assert!(page2.cursor.is_none());
}

#[tokio::test]
async fn feed_hot_ranks_older_high_engagement_beyond_previous_candidate_cap() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    let now = 1_700_000_000_000_000_000;
    let older = seed_thing(
        &state.pool,
        DID_A,
        "3aaaaaaaaaaaa",
        "older-popular",
        &[],
        1_000_000,
    )
    .await;
    set_thing_created_at(&state.pool, &older, now - 30 * 86_400_000_000_000).await;

    for i in 0..501 {
        let rkey = format!("3new{i:010}");
        let uri = seed_thing(&state.pool, DID_A, &rkey, &format!("new-{i}"), &[], 0).await;
        set_thing_created_at(&state.pool, &uri, now - 3_600_000_000_000 + i).await;
    }

    let feed = views::feed_hot_at(&state, 5, None, None, now)
        .await
        .unwrap();
    assert_eq!(feed.items[0].thing.uri.as_ref(), older);
    assert_eq!(feed.items[0].thing.name.as_str(), "older-popular");
}

#[tokio::test]
async fn search_things_uses_fts() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_thing(&state.pool, DID_A, "r1", "Calibration Cube", &[], 0).await;
    seed_thing(&state.pool, DID_A, "r2", "Friendly Sphere", &[], 0).await;

    let feed = views::search_things(&state, "cube", 10, None, None)
        .await
        .unwrap();
    assert_eq!(feed.items.len(), 1);
    assert_eq!(feed.items[0].thing.name.as_str(), "Calibration Cube");
}

#[tokio::test]
async fn malformed_ranked_cursor_is_rejected() {
    let state = state().await;
    let search_err = views::search_things(&state, "x", 10, Some("not-a-number"), None)
        .await
        .unwrap_err();
    assert!(matches!(search_err, AppError::InvalidRequest(_)));
    let hot_err = views::feed_hot(&state, 10, Some("not-a-hot-cursor"), None)
        .await
        .unwrap_err();
    assert!(matches!(hot_err, AppError::InvalidRequest(_)));
}

#[tokio::test]
async fn list_filters_non_thing_subjects() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    let thing = seed_thing(&state.pool, DID_A, "t1", "Thing", &[], 0).await;
    let list = "at://did:plc:l/space.polymodel.graph.list/self";
    let non_thing = "at://did:plc:x/space.polymodel.graph.like/k1";
    sqlx::query("INSERT INTO listitems (did, rkey, cid, list_uri, subject_uri, created_at) VALUES (?, ?, ?, ?, ?, ?)")
        .bind(DID_A).bind("i1").bind("bafyreilistitemone").bind(list).bind(&thing).bind(1)
        .execute(&state.pool).await.unwrap();
    sqlx::query("INSERT INTO listitems (did, rkey, cid, list_uri, subject_uri, created_at) VALUES (?, ?, ?, ?, ?, ?)")
        .bind(DID_A).bind("i2").bind("bafyreilistitemtwo").bind(list).bind(non_thing).bind(2)
        .execute(&state.pool).await.unwrap();

    let (items, _cursor) = views::list_things(&state, list, 10, None, None)
        .await
        .unwrap();
    assert_eq!(items.len(), 1, "non-thing list item must be filtered out");
    assert_eq!(items[0].uri.as_ref(), thing);
}

#[tokio::test]
async fn polymodel_profile_variant_carries_stats() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_profile(&state.pool, DID_A, "Alice").await;
    seed_thing(&state.pool, DID_A, "t1", "Thing One", &[], 4).await;
    seed_thing(&state.pool, DID_A, "t2", "Thing Two", &[], 6).await;

    let view = views::polymodel_profile_view(&state, &did(DID_A))
        .await
        .unwrap()
        .expect("polymodel profile exists");
    assert_eq!(view.did.as_ref(), DID_A);
    assert_eq!(view.handle.as_str(), "alice.com");
    assert_eq!(
        view.display_name.as_ref().map(|s| s.as_str()),
        Some("Alice")
    );
    assert_eq!(view.thing_count, Some(2));
    assert_eq!(view.total_likes, Some(10));
    assert_eq!(
        view.avatar.as_ref().unwrap().as_ref(),
        "https://cdn.bsky.app/img/avatar/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiavatar@jpeg"
    );
}

#[tokio::test]
async fn polymodel_profile_absent_returns_none() {
    let state = state().await;
    let view = views::polymodel_profile_view(&state, &did(DID_B))
        .await
        .unwrap();
    assert!(view.is_none());
}

#[tokio::test]
async fn union_serializes_type_tag() {
    // The polymodel variant must discriminate on $type without a network fetch.
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_profile(&state.pool, DID_A, "Alice").await;
    let view = views::polymodel_profile_view(&state, &did(DID_A))
        .await
        .unwrap()
        .unwrap();
    let union = ProfileViewUnion::ProfileView(Box::new(view));
    let json = serde_json::to_value(&union).unwrap();
    assert_eq!(
        json.get("$type").and_then(|v| v.as_str()),
        Some("space.polymodel.actor.defs#profileView")
    );
}

// --- router-level (ExtractXrpc + handler + response) ---

async fn body_text(resp: axum::response::Response) -> String {
    let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
        .await
        .unwrap();
    String::from_utf8(bytes.to_vec()).unwrap()
}

#[tokio::test]
async fn get_session_route_returns_unauthenticated_marker() {
    let state = state().await;
    let app = super::router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/xrpc/space.polymodel.actor.getSession")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_text(resp).await;
    assert!(body.contains("\"authenticated\":false"), "body was: {body}");
}

#[tokio::test]
async fn get_thing_route_decodes_query_and_404s_when_missing() {
    let state = state().await;
    let app = super::router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/xrpc/space.polymodel.library.getThing?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.thing%2Fnone")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn unauthenticated_write_routes_return_xrpc_401_not_redirects() {
    let state = state().await;
    let app = super::router().with_state(state);
    let routes = [
        (
            "/xrpc/space.polymodel.actor.bootstrapProfile",
            "application/json",
            "{}",
        ),
        (
            "/xrpc/space.polymodel.library.stageFile",
            "model/stl",
            "solid cube",
        ),
        (
            "/xrpc/space.polymodel.graph.createLike",
            "application/json",
            r#"{"subject":{"uri":"at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/t1","cid":"bafycid"}}"#,
        ),
    ];

    for (path, content_type, body) in routes {
        let resp = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri(path)
                    .header("content-type", content_type)
                    .body(Body::from(body))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "route {path}");
        assert!(
            resp.headers().get("location").is_none(),
            "strict XRPC auth must not redirect for {path}"
        );
        let body = body_text(resp).await;
        assert!(body.contains("AuthenticationRequired"), "body was: {body}");
    }
}

#[tokio::test]
async fn negative_offset_cursor_is_rejected() {
    let state = state().await;
    let errs = [
        views::search_things(&state, "x", 10, Some("-1"), None)
            .await
            .unwrap_err(),
        views::list_things(
            &state,
            "at://did:plc:l/space.polymodel.graph.list/self",
            10,
            Some("-1"),
            None,
        )
        .await
        .unwrap_err(),
    ];
    for err in errs {
        assert!(
            matches!(err, AppError::InvalidRequest(_)),
            "negative offset cursor must be InvalidRequest"
        );
    }
}

#[tokio::test]
async fn feed_recent_paginates_by_rkey() {
    let state = state().await;
    seed_identity(&state.pool, DID_A, "alice.com").await;
    seed_thing(&state.pool, DID_A, "3zzzzzzzzzzzz", "t3", &[], 0).await;
    seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await;
    seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await;

    let page1 = views::feed_recent(&state, 2, None, None).await.unwrap();
    assert_eq!(page1.items.len(), 2);
    assert_eq!(page1.items[0].thing.name.as_str(), "t3");
    let cursor = page1.cursor.expect("more remain");
    let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref()), None)
        .await
        .unwrap();
    assert_eq!(page2.items.len(), 1);
    assert_eq!(page2.items[0].thing.name.as_str(), "t1");
    assert!(page2.cursor.is_none());
}

// ---------------------------------------------------------------------------
// PM-47 passthrough proxy: faithful forwarding, read-your-writes override,
// eager projection, structural allowlist, and session gating.
// ---------------------------------------------------------------------------

use jacquard_common::deps::bytes::Bytes;
use jacquard_common::error::{AuthError, ClientError};
use jacquard_common::xrpc::Response as XrpcResponse;
use polymodel_api::com_atproto::repo::get_record::GetRecordResponse;
use serde_json::Value;

use crate::indexing::projection::{
    ProjectionEvent, ProjectionInput, project_eager_record, project_event,
};

#[tokio::test]
async fn forward_xrpc_passes_through_success_body() {
    let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Ok(XrpcResponse::new(
        Bytes::from_static(b"{\"uri\":\"at://x\"}"),
        StatusCode::OK,
    )));
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(body_text(resp).await, "{\"uri\":\"at://x\"}");
}

#[tokio::test]
async fn forward_xrpc_forwards_400_xrpc_error_verbatim() {
    // atproto getRecord RecordNotFound is HTTP 400 + an error body (route c: an
    // Ok response with a non-success status). The proxy must NOT turn it into 500.
    let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Ok(XrpcResponse::new(
        Bytes::from_static(b"{\"error\":\"RecordNotFound\",\"message\":\"Record not found\"}"),
        StatusCode::BAD_REQUEST,
    )));
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    assert!(body_text(resp).await.contains("RecordNotFound"));
}

#[tokio::test]
async fn forward_xrpc_forwards_http_error_status_and_body() {
    // route b: Err(Http) for a non-2xx that isn't 400/401 (e.g. 409 InvalidSwap).
    let err = ClientError::http(
        StatusCode::CONFLICT,
        Some(Bytes::from_static(b"{\"error\":\"InvalidSwap\"}")),
    );
    let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Err(err));
    assert_eq!(resp.status(), StatusCode::CONFLICT);
    assert!(body_text(resp).await.contains("InvalidSwap"));
}

#[tokio::test]
async fn forward_xrpc_forwards_429_and_500_verbatim() {
    for status in [
        StatusCode::TOO_MANY_REQUESTS,
        StatusCode::INTERNAL_SERVER_ERROR,
    ] {
        let err = ClientError::http(status, Some(Bytes::from_static(b"{}")));
        let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Err(err));
        assert_eq!(resp.status(), status);
    }
}

#[tokio::test]
async fn forward_xrpc_auth_error_is_401() {
    let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Err(ClientError::auth(
        AuthError::NotAuthenticated,
    )));
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn forward_xrpc_transport_error_is_500() {
    // Genuine transport/internal failures are the only thing that becomes 500.
    let err = ClientError::transport(std::io::Error::other("boom"));
    let resp = super::proxy::forward_xrpc::<GetRecordResponse>(Err(err));
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

#[tokio::test]
async fn override_returns_local_for_pending_write_with_divergent_cid() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    sqlx::query("INSERT INTO pending_writes (uri, cid, value, written_at) VALUES (?, ?, ?, ?)")
        .bind(&uri)
        .bind("LOCALCID")
        .bind(json!({"name": "fresh"}).to_string())
        .bind(1i64)
        .execute(&pool)
        .await
        .unwrap();
    let resp = super::proxy::try_get_record_override(&pool, &uri, Some("PDSCID"))
        .await
        .unwrap()
        .expect("divergent pending write overrides the PDS result");
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_text(resp).await;
    assert!(body.contains("LOCALCID"), "{body}");
    assert!(body.contains("fresh"), "{body}");
    assert!(!body.contains("stale"), "{body}");
}

#[tokio::test]
async fn override_returns_not_found_for_pending_delete() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    sqlx::query("INSERT INTO pending_deletes (uri, deleted_at) VALUES (?, ?)")
        .bind(&uri)
        .bind(1i64)
        .execute(&pool)
        .await
        .unwrap();
    let resp = super::proxy::try_get_record_override(&pool, &uri, Some("PDSCID"))
        .await
        .unwrap()
        .expect("pending delete overrides the PDS result");
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    assert!(body_text(resp).await.contains("RecordNotFound"));
}

#[tokio::test]
async fn override_is_none_when_no_pending_state() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    assert!(
        super::proxy::try_get_record_override(&pool, &uri, Some("PDSCID"))
            .await
            .unwrap()
            .is_none()
    );
}

#[tokio::test]
async fn override_is_none_when_pending_write_cid_matches_pds() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    sqlx::query("INSERT INTO pending_writes (uri, cid, value, written_at) VALUES (?, ?, ?, ?)")
        .bind(&uri)
        .bind("SAMECID")
        .bind("{}")
        .bind(1i64)
        .execute(&pool)
        .await
        .unwrap();
    // Cids match → the firehose has effectively confirmed → forward the PDS.
    assert!(
        super::proxy::try_get_record_override(&pool, &uri, Some("SAMECID"))
            .await
            .unwrap()
            .is_none()
    );
}

#[tokio::test]
async fn override_returns_local_when_pds_reports_not_found() {
    // A pending write must win even when a lagging PDS reports not-found
    // (pds_cid is None) rather than a divergent record — the core
    // read-your-writes case.
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    sqlx::query("INSERT INTO pending_writes (uri, cid, value, written_at) VALUES (?, ?, ?, ?)")
        .bind(&uri)
        .bind("LOCALCID")
        .bind(json!({"name": "fresh"}).to_string())
        .bind(1i64)
        .execute(&pool)
        .await
        .unwrap();
    let resp = super::proxy::try_get_record_override(&pool, &uri, None)
        .await
        .unwrap()
        .expect("pending write wins over a lagging not-found");
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_text(resp).await;
    assert!(body.contains("LOCALCID"), "{body}");
    assert!(body.contains("fresh"), "{body}");
}

fn poly_event(action: &str, record: Option<Value>) -> ProjectionEvent {
    ProjectionEvent {
        seq: 1,
        did: DID_A.to_string(),
        collection: "space.polymodel.library.thing".to_string(),
        rkey: "t1".to_string(),
        action: action.to_string(),
        record,
        cid: Some("C1".to_string()),
    }
}

#[tokio::test]
async fn eager_write_marks_pending_and_firehose_clears_it() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    let record =
        json!({"name": "x", "license": "CC-BY-4.0", "createdAt": "2024-01-01T00:00:00.000Z"});
    project_eager_record(&pool, &poly_event("create", Some(record.clone())))
        .await
        .unwrap();
    let pending_cid: String = sqlx::query_scalar("SELECT cid FROM pending_writes WHERE uri = ?")
        .bind(&uri)
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(pending_cid, "C1");

    // The Hydrant #commit re-delivering the same create clears the pending row.
    project_event(
        &pool,
        &ProjectionInput::Record(poly_event("create", Some(record))),
    )
    .await
    .unwrap();
    let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_writes WHERE uri = ?")
        .bind(&uri)
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(remaining, 0);
}

#[tokio::test]
async fn eager_delete_marks_pending_and_firehose_clears_it() {
    let pool = pool().await;
    let uri = format!("at://{DID_A}/space.polymodel.library.thing/t1");
    project_eager_record(&pool, &poly_event("delete", None))
        .await
        .unwrap();
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_deletes WHERE uri = ?")
        .bind(&uri)
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(n, 1);

    project_event(&pool, &ProjectionInput::Record(poly_event("delete", None)))
        .await
        .unwrap();
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_deletes WHERE uri = ?")
        .bind(&uri)
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(n, 0);
}

#[tokio::test]
async fn proxy_structural_allowlist_404s_non_allowlisted_methods() {
    let state = state().await;
    let app = super::router().with_state(state);
    for path in [
        "/xrpc/com.atproto.repo.applyWrites",
        "/xrpc/com.atproto.repo.importRepo",
        "/xrpc/com.atproto.repo.listMissingBlobs",
        "/xrpc/app.bsky.feed.getTimeline",
        "/xrpc/com.atproto.server.getSession",
    ] {
        let resp = app
            .clone()
            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            StatusCode::NOT_FOUND,
            "{path} must not be proxied"
        );
    }
}

#[tokio::test]
async fn proxy_pds_routes_require_an_oauth_session() {
    let state = state().await;
    let app = super::router().with_state(state);
    // createRecord (procedure) with no session → 401.
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/xrpc/com.atproto.repo.createRecord")
                .header("content-type", "application/json")
                .body(Body::from("{}"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    // getRecord (query) with no session → 401.
    let resp = app
        .oneshot(
            Request::builder()
                .uri(
                    "/xrpc/com.atproto.repo.getRecord?repo=did:plc:x\
                     &collection=space.polymodel.library.thing&rkey=t1",
                )
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
