use super::*;

// ── BUG-004: validate_field rejects empty and whitespace-only strings ──

#[test]
fn validate_short_rejects_empty_string() {
    let result = validate_short("agent_id", "");
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert_eq!(err.0, StatusCode::BAD_REQUEST);
    assert!(err.1.contains("must not be empty"));
}

#[test]
fn validate_short_rejects_whitespace_only() {
    let result = validate_short("name", "   ");
    assert!(result.is_err());
}

#[test]
fn validate_long_rejects_empty_string() {
    let result = validate_long("description", "");
    assert!(result.is_err());
}

#[test]
fn validate_short_rejects_null_bytes() {
    let result = validate_short("agent_id", "hello\0world");
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.1.contains("null bytes"));
}

#[test]
fn validate_short_accepts_valid_input() {
    assert!(validate_short("agent_id", "my-agent").is_ok());
    assert!(validate_short("name", "a").is_ok());
}

#[test]
fn validate_short_rejects_over_max_length() {
    let long = "a".repeat(MAX_SHORT_FIELD + 1);
    let result = validate_short("agent_id", &long);
    assert!(result.is_err());
}

#[test]
fn validate_short_at_exact_max_length() {
    let exact = "a".repeat(MAX_SHORT_FIELD);
    assert!(validate_short("agent_id", &exact).is_ok());
}

// ── BUG-009: sanitize_html strips HTML tags ──

#[test]
fn sanitize_html_escapes_script_tags() {
    let input = "<script>alert(1)</script>";
    let output = sanitize_html(input);
    assert!(!output.contains('<'));
    assert!(!output.contains('>'));
    assert!(output.contains("&lt;"));
    assert!(output.contains("&gt;"));
}

#[test]
fn sanitize_html_preserves_safe_content() {
    assert_eq!(sanitize_html("hello world"), "hello world");
}

#[test]
fn sanitize_html_escapes_all_entities() {
    // S-MED-1: must escape & " ' for attribute-context XSS
    assert_eq!(sanitize_html("a&b"), "a&amp;b");
    assert_eq!(
        sanitize_html(r#"" onmouseover="x"#),
        "&quot; onmouseover=&quot;x"
    );
    assert_eq!(sanitize_html("' onclick='y"), "&#x27; onclick=&#x27;y");
    // & before < to avoid double-escaping
    assert_eq!(sanitize_html("&lt;"), "&amp;lt;");
}

// ── BUG-007/008: PaginationQuery clamps limits ──

#[test]
fn pagination_resolve_defaults() {
    let pq = PaginationQuery {
        limit: None,
        offset: None,
    };
    let (limit, offset) = pq.resolve();
    assert_eq!(limit, DEFAULT_PAGE_SIZE);
    assert_eq!(offset, 0);
}

#[test]
fn pagination_resolve_clamps_negative_limit() {
    let pq = PaginationQuery {
        limit: Some(-1),
        offset: None,
    };
    let (limit, _) = pq.resolve();
    assert_eq!(limit, 1);
}

#[test]
fn pagination_resolve_clamps_zero_limit() {
    let pq = PaginationQuery {
        limit: Some(0),
        offset: None,
    };
    let (limit, _) = pq.resolve();
    assert_eq!(limit, 1);
}

#[test]
fn pagination_resolve_clamps_huge_limit() {
    let pq = PaginationQuery {
        limit: Some(999_999),
        offset: None,
    };
    let (limit, _) = pq.resolve();
    assert_eq!(limit, MAX_PAGE_SIZE);
}

#[test]
fn pagination_resolve_clamps_negative_offset() {
    let pq = PaginationQuery {
        limit: None,
        offset: Some(-5),
    };
    let (_, offset) = pq.resolve();
    assert_eq!(offset, 0);
}

// ── BUG-006: Malformed JSON returns JSON error ──

#[tokio::test]
async fn malformed_json_returns_json_error_body() {
    let app = full_app(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions")
                .header("content-type", "application/json")
                .body(Body::from("{not valid json"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = json_body(resp).await;
    assert!(
        body["detail"].is_string(),
        "error response must be JSON with 'error' field"
    );
}

#[tokio::test]
async fn wrong_content_type_returns_json_error() {
    let app = full_app(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions")
                .header("content-type", "text/plain")
                .body(Body::from("{\"agent_id\":\"test\"}"))
                .unwrap(),
        )
        .await
        .unwrap();
    // Should be 415 wrapped in JSON
    assert!(resp.status().is_client_error());
    let body = json_body(resp).await;
    assert!(body["detail"].is_string());
}

// ── BUG-017: 405 returns JSON body ──

#[tokio::test]
async fn method_not_allowed_returns_json_body() {
    let app = full_app(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
    let body = json_body(resp).await;
    assert!(body["detail"].is_string());
}

// ── BUG-018/019: Security headers present ──

#[tokio::test]
async fn security_headers_present_on_response() {
    let app = full_app(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let headers = resp.headers();
    assert!(
        headers.contains_key("content-security-policy"),
        "CSP header must be present"
    );
    assert!(
        headers.contains_key("x-frame-options"),
        "X-Frame-Options must be present"
    );
    assert_eq!(
        headers.get("x-frame-options").unwrap().to_str().unwrap(),
        "DENY"
    );
    assert!(
        headers.contains_key("x-content-type-options"),
        "X-Content-Type-Options must be present"
    );
    assert_eq!(
        headers
            .get("x-content-type-options")
            .unwrap()
            .to_str()
            .unwrap(),
        "nosniff"
    );
}

// ── BUG-003: Session list supports pagination ──

#[tokio::test]
async fn session_list_respects_limit_parameter() {
    let state = test_state();
    // Create 5 sessions by rotating different agent IDs
    for i in 0..5 {
        roboticus_db::sessions::rotate_agent_session(&state.db, &format!("agent-{i}")).unwrap();
    }
    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/sessions?limit=2")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    let sessions = body["sessions"].as_array().unwrap();
    assert_eq!(sessions.len(), 2);
}

// ── BUG-004 integration: Empty agent_id rejected by POST /api/sessions ──

#[tokio::test]
async fn empty_agent_id_falls_back_to_config_default() {
    let app = full_app(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"agent_id":""}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    // Empty agent_id falls back to server config default — session creates successfully
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert!(body["id"].as_str().is_some());
}

// ── BUG-026: Model change persisted to disk ──

#[tokio::test]
async fn change_model_persists_to_disk() {
    let state = test_state();
    let config_path = state.config_path.as_ref().clone();
    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/roster/TestBot/model")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"model":"anthropic/claude-sonnet-4-20250514"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["updated"], true);
    assert_eq!(body["persisted"], true);
    // Verify on disk
    let contents = std::fs::read_to_string(&config_path).unwrap();
    assert!(
        contents.contains("claude-sonnet"),
        "config file should contain the new model"
    );
}

// ══════════════════════════════════════════════════════════════
//  Phase 3: Session / Turn / Interview / Feedback Route Tests
// ══════════════════════════════════════════════════════════════

// ── GET /api/sessions/{id} ────────────────────────────────────

#[tokio::test]
async fn get_session_returns_full_object() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "test-agent", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["id"], sid);
    assert_eq!(body["agent_id"], "test-agent");
    assert!(body["created_at"].is_string());
}

#[tokio::test]
async fn get_session_nonexistent_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/sessions/nonexistent-session-id")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── POST /api/sessions (create via rotate) ────────────────────

#[tokio::test]
async fn create_session_returns_full_session_object() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"agent_id":"agent-alpha"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert!(body["id"].is_string());
    assert_eq!(body["agent_id"], "agent-alpha");
    assert!(body["created_at"].is_string());
}

// ── GET /api/sessions/{id}/turns ──────────────────────────────

#[tokio::test]
async fn list_session_turns_empty() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-a", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/turns"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turns"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn list_session_turns_returns_seeded_turn() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-b", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-lst-1",
        &sid,
        Some("gpt-4"),
        Some(200),
        Some(100),
        Some(0.02),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/turns"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    let turns = body["turns"].as_array().unwrap();
    assert_eq!(turns.len(), 1);
    assert_eq!(turns[0]["id"], "turn-lst-1");
    assert_eq!(turns[0]["model"], "gpt-4");
}

// ── GET /api/turns/{id} ───────────────────────────────────────

#[tokio::test]
async fn get_turn_returns_turn_data() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-c", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-get-1",
        &sid,
        Some("claude-3"),
        Some(500),
        Some(250),
        Some(0.05),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-get-1")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["id"], "turn-get-1");
    assert_eq!(body["session_id"], sid);
    assert_eq!(body["model"], "claude-3");
    assert_eq!(body["tokens_in"], 500);
    assert_eq!(body["tokens_out"], 250);
}

#[tokio::test]
async fn get_turn_nonexistent_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/nonexistent-turn-id")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/turns/{id}/tools ─────────────────────────────────

#[tokio::test]
async fn get_turn_tools_empty() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-e", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-tools-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(test_state()); // fresh state, no tool calls
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-tools-1/tools")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["tool_calls"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn get_turn_tools_with_seeded_tool_call() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-f", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-tools-2",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();
    roboticus_db::tools::record_tool_call(
        &state.db,
        "turn-tools-2",
        "file_read",
        r#"{"path":"test.rs"}"#,
        Some(r#"{"content":"hello"}"#),
        "success",
        Some(100),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-tools-2/tools")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    let calls = body["tool_calls"].as_array().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0]["tool_name"], "file_read");
    assert_eq!(calls[0]["status"], "success");
}

// ── GET /api/turns/{id}/tips ──────────────────────────────────

#[tokio::test]
async fn get_turn_tips_returns_array() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-g", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-tips-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-tips-1/tips")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turn_id"], "turn-tips-1");
    assert!(body["tips"].is_array());
    assert!(body["tip_count"].is_number());
}

#[tokio::test]
async fn get_turn_tips_nonexistent_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/nonexistent/tips")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── POST /api/turns/{id}/feedback ─────────────────────────────

#[tokio::test]
async fn post_turn_feedback_succeeds() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-fb-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/turns/turn-fb-1/feedback")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"grade":4,"comment":"good response"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turn_id"], "turn-fb-1");
    assert_eq!(body["grade"], 4);
}

#[tokio::test]
async fn post_turn_feedback_invalid_grade_returns_400() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-fbv", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-fbv-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/turns/turn-fbv-1/feedback")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"grade":6}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn post_turn_feedback_nonexistent_turn_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/turns/nonexistent/feedback")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"grade":3}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/turns/{id}/feedback ──────────────────────────────

#[tokio::test]
async fn get_turn_feedback_returns_seeded_feedback() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-gfb", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-gfb-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();
    roboticus_db::sessions::record_feedback(
        &state.db,
        "turn-gfb-1",
        &sid,
        5,
        "dashboard",
        Some("excellent"),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-gfb-1/feedback")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["grade"], 5);
    assert_eq!(body["comment"], "excellent");
}

#[tokio::test]
async fn get_turn_feedback_no_feedback_returns_404() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-nfb", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-nfb-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/turn-nfb-1/feedback")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/sessions/{id}/feedback ───────────────────────────

#[tokio::test]
async fn get_session_feedback_returns_list() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-sfb", None).unwrap();
    roboticus_db::sessions::create_turn_with_id(
        &state.db,
        "turn-sfb-1",
        &sid,
        Some("gpt-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();
    roboticus_db::sessions::record_feedback(&state.db, "turn-sfb-1", &sid, 3, "dashboard", None)
        .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/feedback"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    let fbs = body["feedback"].as_array().unwrap();
    assert_eq!(fbs.len(), 1);
    assert_eq!(fbs[0]["grade"], 3);
}

// ── GET /api/sessions/{id}/insights ───────────────────────────

#[tokio::test]
async fn get_session_insights_returns_valid_shape() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-ins", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/insights"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["session_id"], sid);
    assert!(body["insights"].is_array());
    assert!(body["insight_count"].is_number());
    assert_eq!(body["turn_count"], 0);
}

// ── POST /api/sessions/{id}/messages ──────────────────────────

#[tokio::test]
async fn post_message_invalid_role_returns_400() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-pm", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(format!("/api/sessions/{sid}/messages"))
                .header("content-type", "application/json")
                .body(Body::from(r#"{"role":"invalid_role","content":"hello"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn post_message_nonexistent_session_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions/nonexistent/messages")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"role":"user","content":"hello"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── POST /api/interview/start (duplicate returns 409) ─────────

#[tokio::test]
async fn interview_start_duplicate_key_returns_conflict() {
    let state = test_state();
    let app = build_router(state.clone());
    // Start first interview
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/interview/start")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"session_key":"dup-key"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    // Start duplicate interview
    let app2 = build_router(state);
    let resp2 = app2
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/interview/start")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"session_key":"dup-key"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp2.status(), StatusCode::CONFLICT);
}

// ── POST /api/interview/finish (not found) ────────────────────

#[tokio::test]
async fn interview_finish_unknown_key_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/interview/finish")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"session_key":"nonexistent"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── POST /api/interview/turn (unknown session) ────────────────

#[tokio::test]
async fn interview_turn_unknown_key_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/interview/turn")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"session_key":"nonexistent","content":"hello"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── POST /api/sessions/backfill-nicknames ─────────────────────

#[tokio::test]
async fn backfill_nicknames_returns_ok() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/sessions/backfill-nicknames")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert!(body["backfilled"].is_number());
}

// ── GET /api/sessions/{id}/messages (empty, then with msg) ────

#[tokio::test]
async fn list_messages_empty_session() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-lm", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/messages"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["messages"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn list_messages_returns_seeded_message() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-lm2", None).unwrap();
    roboticus_db::sessions::append_message(&state.db, &sid, "user", "hello world").unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/messages"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    let msgs = body["messages"].as_array().unwrap();
    assert_eq!(msgs.len(), 1);
    assert_eq!(msgs[0]["role"], "user");
    assert_eq!(msgs[0]["content"], "hello world");
}

// ══════════════════════════════════════════════════════════════
//  Phase 4 — Skills, Model Selection, Feedback, Context, Channels
// ══════════════════════════════════════════════════════════════

// ── GET /api/skills/:id (found) ─────────────────────────────

#[tokio::test]
async fn get_skill_found() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "test-skill",
        "instruction",
        Some("A test skill"),
        "/tmp/test.md",
        "hash123",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/skills/{skill_id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["name"], "test-skill");
    assert_eq!(body["kind"], "instruction");
    assert_eq!(body["built_in"], false);
    assert_eq!(body["enabled"], true);
}

// ── GET /api/skills/:id (not found) ─────────────────────────

#[tokio::test]
async fn get_skill_by_id_returns_404() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/skills/nonexistent-id")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── PUT /api/skills/:id/toggle (not found) ──────────────────

#[tokio::test]
async fn toggle_skill_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/skills/nonexistent/toggle")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── PUT /api/skills/:id/toggle (success) ──────────────────

#[tokio::test]
async fn toggle_skill_success() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "toggleable",
        "instruction",
        None,
        "/tmp/t.md",
        "h1",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/skills/{skill_id}/toggle"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["id"], skill_id);
    // Was enabled (true), after toggle should be false
    assert_eq!(body["enabled"], false);
}

// ── PUT /api/skills/:id/toggle (forbidden for builtin) ──────

#[tokio::test]
async fn toggle_skill_forbidden_for_builtin() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "builtin-skill",
        "builtin",
        None,
        "/tmp/b.md",
        "h2",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/skills/{skill_id}/toggle"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn update_skill_source_success() {
    let state = test_state();
    let skills_dir = tempfile::tempdir().unwrap();
    let source_path = skills_dir.path().join("editable.md");
    std::fs::write(
        &source_path,
        "---\nname: editable\ndescription: old\nkind: instruction\n---\nOld content\n",
    )
    .unwrap();
    {
        let mut cfg = state.config.write().await;
        cfg.skills.skills_dir = skills_dir.path().to_path_buf();
    }
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "editable",
        "instruction",
        Some("old"),
        source_path.to_str().unwrap(),
        "h4",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/skills/{skill_id}"))
                .header("content-type", "application/json")
                .body(Body::from(
                    serde_json::json!({
                        "source_content": "---\nname: editable\ndescription: updated\nkind: instruction\n---\nUpdated content\n"
                    })
                    .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["updated"], true);
    assert_eq!(body["skill"]["name"], "editable");
    assert!(
        std::fs::read_to_string(source_path)
            .unwrap()
            .contains("Updated content")
    );
}

#[tokio::test]
async fn update_skill_source_forbidden_for_builtin() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "builtin-skill-edit",
        "builtin",
        None,
        "/tmp/builtin.md",
        "h5",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/skills/{skill_id}"))
                .header("content-type", "application/json")
                .body(Body::from(
                    serde_json::json!({
                        "source_content": "nope"
                    })
                    .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ── DELETE /api/skills/:id (success) ─────────────────────────

#[tokio::test]
async fn delete_skill_success() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "deletable",
        "instruction",
        None,
        "/tmp/d.md",
        "h3",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri(format!("/api/skills/{skill_id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["deleted"], true);
    assert_eq!(body["name"], "deletable");
}

// ── DELETE /api/skills/:id (not found) ───────────────────────

#[tokio::test]
async fn delete_skill_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/skills/nonexistent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── DELETE /api/skills/:id (forbidden for builtin) ───────────

#[tokio::test]
async fn delete_skill_forbidden_for_builtin() {
    let state = test_state();
    let skill_id = roboticus_db::skills::register_skill_full(
        &state.db,
        "builtin-del",
        "builtin",
        None,
        "/tmp/bd.md",
        "h4",
        None,
        None,
        None,
        None,
        "Safe",
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri(format!("/api/skills/{skill_id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ── GET /api/model-selection/turns/:id (not found) ──────────

#[tokio::test]
async fn get_turn_model_selection_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/model-selection/turns/nonexistent-turn")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/turns/:id/model-selection (found) ──────────────

#[tokio::test]
async fn get_turn_model_selection_found() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-ms", None).unwrap();
    let tid = roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("claude-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();
    let evt = roboticus_db::model_selection::ModelSelectionEventRow {
        id: "mse-test-1".into(),
        turn_id: tid.clone(),
        session_id: sid.clone(),
        agent_id: "agent-ms".into(),
        channel: "cli".into(),
        selected_model: "claude-4".into(),
        strategy: "complexity".into(),
        primary_model: "claude-4".into(),
        override_model: None,
        complexity: Some("high".into()),
        user_excerpt: "test".into(),
        candidates_json: r#"["claude-4"]"#.into(),
        created_at: "2025-01-01T00:00:00".into(),
        schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
        attribution: None,
        metascore_json: None,
        features_json: None,
    };
    roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/turns/{tid}/model-selection"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["selected_model"], "claude-4");
    assert_eq!(body["strategy"], "complexity");
    assert!(body["candidates"].is_array());
}

// ── GET /api/models/selections (empty) ──────────────────────

#[tokio::test]
async fn list_model_selection_events_empty() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/models/selections")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["count"], 0);
    assert_eq!(body["events"].as_array().unwrap().len(), 0);
}

// ── GET /api/models/selections?limit=2 ──────────────────────

#[tokio::test]
async fn list_model_selection_events_with_limit() {
    let state = test_state();
    for i in 0..3 {
        let evt = roboticus_db::model_selection::ModelSelectionEventRow {
            id: format!("mse-list-{i}"),
            turn_id: format!("turn-list-{i}"),
            session_id: "sess-list".into(),
            agent_id: "agent-list".into(),
            channel: "cli".into(),
            selected_model: "gpt-4".into(),
            strategy: "default".into(),
            primary_model: "gpt-4".into(),
            override_model: None,
            complexity: None,
            user_excerpt: "hello".into(),
            candidates_json: "[]".into(),
            created_at: format!("2025-01-0{i}T00:00:00"),
            schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
            attribution: None,
            metascore_json: None,
            features_json: None,
        };
        roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
    }

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/models/selections?limit=2")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["count"], 2);
}

#[tokio::test]
async fn routing_dataset_endpoint_returns_rows_and_summary() {
    let state = test_state();
    let evt = roboticus_db::model_selection::ModelSelectionEventRow {
        id: "mse-dataset-1".into(),
        turn_id: "turn-dataset-1".into(),
        session_id: "sess-dataset".into(),
        agent_id: "agent-dataset".into(),
        channel: "cli".into(),
        selected_model: "ollama/qwen3:8b".into(),
        strategy: "metascore".into(),
        primary_model: "ollama/qwen3:8b".into(),
        override_model: None,
        complexity: Some("0.42".into()),
        user_excerpt: "dataset test".into(),
        candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
        created_at: "2025-01-01T00:00:00".into(),
        schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
        attribution: Some("unit-test".into()),
        metascore_json: None,
        features_json: None,
    };
    roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
    roboticus_db::metrics::record_inference_cost(
        &state.db,
        "ollama/qwen3:8b",
        "ollama",
        100,
        50,
        0.001,
        Some("T1"),
        false,
        Some(120),
        Some(0.8),
        false,
        Some("turn-dataset-1"),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/models/routing-dataset?limit=10")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["summary"]["total_rows"], 1);
    assert_eq!(body["rows"].as_array().unwrap().len(), 1);
    assert_eq!(body["rows"][0]["user_excerpt"], "[redacted]");
}

#[tokio::test]
async fn routing_dataset_endpoint_can_include_user_excerpt_when_opted_in() {
    let state = test_state();
    let evt = roboticus_db::model_selection::ModelSelectionEventRow {
        id: "mse-dataset-2".into(),
        turn_id: "turn-dataset-2".into(),
        session_id: "sess-dataset".into(),
        agent_id: "agent-dataset".into(),
        channel: "cli".into(),
        selected_model: "ollama/qwen3:8b".into(),
        strategy: "metascore".into(),
        primary_model: "ollama/qwen3:8b".into(),
        override_model: None,
        complexity: Some("0.18".into()),
        user_excerpt: "sensitive excerpt".into(),
        candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
        created_at: "2025-01-01T00:00:00".into(),
        schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
        attribution: Some("unit-test".into()),
        metascore_json: None,
        features_json: None,
    };
    roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
    roboticus_db::metrics::record_inference_cost(
        &state.db,
        "ollama/qwen3:8b",
        "ollama",
        40,
        20,
        0.0005,
        Some("T1"),
        false,
        Some(80),
        Some(0.7),
        false,
        Some("turn-dataset-2"),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/models/routing-dataset?limit=10&include_user_excerpt=true")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["rows"][0]["user_excerpt"], "sensitive excerpt");
}

#[tokio::test]
async fn routing_eval_endpoint_returns_summary() {
    let state = test_state();
    let evt = roboticus_db::model_selection::ModelSelectionEventRow {
        id: "mse-eval-1".into(),
        turn_id: "turn-eval-1".into(),
        session_id: "sess-eval".into(),
        agent_id: "agent-eval".into(),
        channel: "cli".into(),
        selected_model: "ollama/qwen3:8b".into(),
        strategy: "metascore".into(),
        primary_model: "ollama/qwen3:8b".into(),
        override_model: None,
        complexity: Some("0.25".into()),
        user_excerpt: "eval test".into(),
        candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
        created_at: "2025-01-01T00:00:00".into(),
        schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
        attribution: Some("unit-test".into()),
        metascore_json: None,
        features_json: None,
    };
    roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
    roboticus_db::metrics::record_inference_cost(
        &state.db,
        "ollama/qwen3:8b",
        "ollama",
        120,
        60,
        0.002,
        Some("T1"),
        false,
        Some(110),
        Some(0.85),
        false,
        Some("turn-eval-1"),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/models/routing-eval")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"limit":100,"include_verdicts":true,"cost_aware":false}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert!(body["rows_considered"].as_u64().unwrap_or(0) >= 1);
    assert!(body["summary"]["total_rows"].as_u64().unwrap_or(0) >= 1);
    assert!(body["verdicts"].is_array());
}

#[tokio::test]
async fn routing_eval_endpoint_rejects_invalid_weights() {
    let state = test_state();
    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/models/routing-eval")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"cost_weight":1.3,"accuracy_floor":-0.2,"accuracy_min_obs":0}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn routing_dataset_endpoint_rejects_invalid_since_format() {
    let state = test_state();
    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/models/routing-dataset?since=not-a-date")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn routing_eval_endpoint_rejects_invalid_until_format() {
    let state = test_state();
    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/models/routing-eval")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"until":"bad-date"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn routing_eval_endpoint_rejects_malformed_candidates_json() {
    let state = test_state();
    let evt = roboticus_db::model_selection::ModelSelectionEventRow {
        id: "mse-eval-bad-candidates".into(),
        turn_id: "turn-eval-bad-candidates".into(),
        session_id: "sess-eval-bad-candidates".into(),
        agent_id: "agent-eval".into(),
        channel: "cli".into(),
        selected_model: "ollama/qwen3:8b".into(),
        strategy: "metascore".into(),
        primary_model: "ollama/qwen3:8b".into(),
        override_model: None,
        complexity: Some("0.4".into()),
        user_excerpt: "eval malformed candidates".into(),
        candidates_json: "this-is-not-json".into(),
        created_at: "2025-01-01T00:00:00".into(),
        schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
        attribution: Some("unit-test".into()),
        metascore_json: None,
        features_json: None,
    };
    roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
    roboticus_db::metrics::record_inference_cost(
        &state.db,
        "ollama/qwen3:8b",
        "ollama",
        50,
        25,
        0.001,
        Some("T1"),
        false,
        Some(80),
        Some(0.5),
        false,
        Some("turn-eval-bad-candidates"),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/models/routing-eval")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"limit":50000,"since":"2025-01-01","until":"2025-01-02"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ── PUT /api/turns/:id/feedback (update existing) ───────────

#[tokio::test]
async fn put_turn_feedback_updates_grade() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb", None).unwrap();
    let tid = roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("claude-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();
    // Seed initial feedback
    roboticus_db::sessions::record_feedback(&state.db, &tid, &sid, 3, "dashboard", Some("ok"))
        .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/turns/{tid}/feedback"))
                .header("content-type", "application/json")
                .body(Body::from(r#"{"grade":5,"comment":"great"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["grade"], 5);
    assert_eq!(body["updated"], true);
}

// ── PUT /api/turns/:id/feedback (invalid grade) ─────────────

#[tokio::test]
async fn put_turn_feedback_rejects_invalid_grade() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/turns/any-turn/feedback")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"grade":0}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ── GET /api/sessions/:id/feedback (empty) ──────────────────

#[tokio::test]
async fn get_session_feedback_empty() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb-empty", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/feedback"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["feedback"].as_array().unwrap().len(), 0);
}

// ── GET /api/sessions/:id/feedback (with data) ──────────────

#[tokio::test]
async fn get_session_feedback_with_entries() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb2", None).unwrap();
    let t1 =
        roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(10), Some(5), Some(0.001))
            .unwrap();
    let t2 =
        roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(20), Some(10), Some(0.002))
            .unwrap();
    roboticus_db::sessions::record_feedback(&state.db, &t1, &sid, 4, "dashboard", None).unwrap();
    roboticus_db::sessions::record_feedback(&state.db, &t2, &sid, 2, "dashboard", Some("bad"))
        .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/feedback"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["feedback"].as_array().unwrap().len(), 2);
}

// ── GET /api/turns/:id/context (found) ──────────────────────

#[tokio::test]
async fn get_turn_context_found() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-ctx", None).unwrap();
    let tid = roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("claude-4"),
        Some(500),
        Some(200),
        Some(0.05),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/turns/{tid}/context"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turn_id"], tid);
    assert_eq!(body["tokens_in"], 500);
    assert_eq!(body["tokens_out"], 200);
    assert_eq!(body["tool_call_count"], 0);
    assert_eq!(body["tool_failure_count"], 0);
}

// ── GET /api/turns/:id/context (not found) ──────────────────

#[tokio::test]
async fn get_turn_context_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/nonexistent/context")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/turns/:id/tools (empty) ────────────────────────

#[tokio::test]
async fn get_turn_tools_returns_empty_list() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-tools", None).unwrap();
    let tid =
        roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(10), Some(5), Some(0.001))
            .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/turns/{tid}/tools"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["tool_calls"].as_array().unwrap().len(), 0);
}

// ── GET /api/turns/:id/tips (found, no tool calls) ──────────

#[tokio::test]
async fn get_turn_tips_found() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-tips", None).unwrap();
    let tid = roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("claude-4"),
        Some(100),
        Some(50),
        Some(0.01),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/turns/{tid}/tips"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turn_id"], tid);
    assert!(body["tips"].is_array());
    assert!(body["tip_count"].is_number());
}

// ── GET /api/turns/:id/tips (not found) ─────────────────────

#[tokio::test]
async fn get_turn_tips_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/turns/nonexistent/tips")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── GET /api/sessions/:id/insights (empty session) ──────────

#[tokio::test]
async fn get_session_insights_empty() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-insights", None).unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/insights"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["session_id"], sid);
    assert!(body["insights"].is_array());
    assert_eq!(body["turn_count"], 0);
}

// ── GET /api/sessions/:id/insights (with turns) ─────────────

#[tokio::test]
async fn get_session_insights_with_turns() {
    let state = test_state();
    let sid = roboticus_db::sessions::create_new(&state.db, "agent-insights2", None).unwrap();
    roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("claude-4"),
        Some(1000),
        Some(500),
        Some(0.1),
    )
    .unwrap();
    roboticus_db::sessions::create_turn(
        &state.db,
        &sid,
        Some("gpt-4"),
        Some(2000),
        Some(1000),
        Some(0.2),
    )
    .unwrap();

    let app = build_router(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri(format!("/api/sessions/{sid}/insights"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["turn_count"], 2);
}

// ── POST /api/webhooks/telegram (not configured) ─────────────

#[tokio::test]
async fn telegram_webhook_not_configured() {
    let app = build_public_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/webhooks/telegram")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"update_id":1}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let body = json_body(resp).await;
    assert_eq!(body["status"], 503);
    assert!(body["detail"].as_str().unwrap().contains("Telegram"));
}

// ── GET /api/webhooks/whatsapp (not configured) ────────────

#[tokio::test]
async fn whatsapp_verify_not_configured() {
    let app = build_public_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=abc&hub.challenge=test123")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

// ── POST /api/webhooks/whatsapp (not configured) ───────────

#[tokio::test]
async fn whatsapp_webhook_not_configured() {
    let app = build_public_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/webhooks/whatsapp")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"entry":[]}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

// ── GET /api/channels/dead-letter (empty) ───────────────────

#[tokio::test]
async fn dead_letters_empty() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/channels/dead-letter")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = json_body(resp).await;
    assert_eq!(body["count"], 0);
}

// ── POST /api/channels/dead-letter/:id/replay (not found) ──

#[tokio::test]
async fn replay_dead_letter_not_found() {
    let app = build_router(test_state());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/channels/dead-letter/fake-id/replay")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ── Auth middleware roundtrip tests ──────────────────────────

#[tokio::test]
async fn protected_route_returns_401_with_wrong_api_key() {
    use crate::auth::ApiKeyLayer;
    let state = test_state();
    let app = build_router(state).layer(ApiKeyLayer::new(Some("correct-key".into())));
    let req = Request::builder()
        .uri("/api/sessions")
        .header("x-api-key", "wrong-key")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn no_api_key_configured_allows_all_requests() {
    use crate::auth::ApiKeyLayer;
    let state = test_state();
    let app = build_router(state).layer(ApiKeyLayer::new(None));
    let req = Request::builder()
        .uri("/api/health")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn auth_middleware_works_with_post_requests() {
    use crate::auth::ApiKeyLayer;
    let state = test_state();
    let app = build_router(state).layer(ApiKeyLayer::new(Some("post-test-key".into())));

    // POST without key → 401
    let req = Request::builder()
        .method("POST")
        .uri("/api/sessions")
        .header("content-type", "application/json")
        .body(Body::from(r#"{"agent_id":"test"}"#))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn auth_middleware_post_with_correct_key() {
    use crate::auth::ApiKeyLayer;
    let state = test_state();
    let app = build_router(state).layer(ApiKeyLayer::new(Some("post-test-key".into())));

    let req = Request::builder()
        .method("POST")
        .uri("/api/sessions")
        .header("content-type", "application/json")
        .header("x-api-key", "post-test-key")
        .body(Body::from(r#"{"agent_id":"test"}"#))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ── SSE streaming endpoint tests ────────────────────────────

#[tokio::test]
async fn stream_rejects_empty_content() {
    let app = build_router(test_state());
    let req = Request::builder()
        .method("POST")
        .uri("/api/agent/message/stream")
        .header("content-type", "application/json")
        .body(Body::from(r#"{"content":"   "}"#))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = json_body(resp).await;
    assert!(body["detail"].as_str().unwrap().contains("empty"));
}

#[tokio::test]
async fn stream_rejects_oversized_content() {
    let app = build_router(test_state());
    let huge = "x".repeat(33_000);
    let payload = serde_json::json!({"content": huge}).to_string();
    let req = Request::builder()
        .method("POST")
        .uri("/api/agent/message/stream")
        .header("content-type", "application/json")
        .body(Body::from(payload))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}

#[tokio::test]
async fn stream_rejects_missing_content_field() {
    let app = build_router(test_state());
    let req = Request::builder()
        .method("POST")
        .uri("/api/agent/message/stream")
        .header("content-type", "application/json")
        .body(Body::from(r#"{}"#))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    // Missing required field → 422 (Unprocessable Entity from axum)
    assert!(
        resp.status() == StatusCode::BAD_REQUEST
            || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
    );
}

// ── Session archive endpoint tests ──────────────────────────

#[tokio::test]
async fn archive_session_endpoint_works() {
    let state = test_state();
    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "test-archive-agent", None).unwrap();

    let app = build_router(state.clone());
    let req = Request::builder()
        .method("POST")
        .uri(format!("/api/sessions/{session_id}/archive"))
        .header("content-type", "application/json")
        .body(Body::from(r#"{"reason":"test"}"#))
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let body = json_body(resp).await;
    assert_eq!(body["archived"], true);
    assert_eq!(body["session_id"].as_str().unwrap(), session_id);

    // Verify the status was persisted
    let session = roboticus_db::sessions::get_session(&state.db, &session_id)
        .unwrap()
        .unwrap();
    assert_eq!(session.status, "archived");
}

#[tokio::test]
async fn archive_session_not_found() {
    let app = build_router(test_state());
    let req = Request::builder()
        .method("POST")
        .uri("/api/sessions/nonexistent-session-id/archive")
        .header("content-type", "application/json")
        .body(Body::from(r#"{}"#))
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
