// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! Phase 2 tests: admin WebUI for moderation
//!
//! Tests for:
//! - Access control (401 anonymous, 403 non-admin, 200 admin)
//! - User management (list, delete with cascade)
//! - Report queue (list, resolve/dismiss, shortcuts)
//! - Federation controls (domain blocks, remote user blocks)

mod support;

use frogue::entities::pending_deletions;
use miette::IntoDiagnostic;
use sea_orm::{ColumnTrait, Database, EntityTrait, PaginatorTrait, QueryFilter};
use support::{Server, free_port};

const PASSWORD: &str = "correct horse battery staple";

/// Registers a user through the form, the way the browser would.
fn register(client: &mut support::Client, name: &str, email: &str) {
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", email), ("password", PASSWORD)],
    );
    assert_eq!(
        response.status, 303,
        "registering {} should redirect on success",
        name
    );
    assert!(
        response.sets_cookie("frogue_session"),
        "registering {} should start a session\n{:?}",
        name,
        response.set_cookies()
    );
}

/// Logs in with the given credentials.
fn login(client: &mut support::Client, name: &str, password: &str) {
    let response = client.post_form("/login", &[("name", name), ("password", password)]);
    assert_eq!(
        response.status, 303,
        "logging in as {} should redirect on success",
        name
    );
    assert!(
        response.sets_cookie("frogue_session"),
        "logging in as {} should start a session\n{:?}",
        name,
        response.set_cookies()
    );
}

// ============================================================================
// Access control tests
// ============================================================================

#[tokio::test]
async fn anonymous_gets_401_on_admin_pages() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Test all admin endpoints
    let response = client.get("/admin");
    assert_eq!(response.status, 401, "anonymous should get 401 on /admin");

    let response = client.get("/admin/users");
    assert_eq!(
        response.status, 401,
        "anonymous should get 401 on /admin/users"
    );

    let response = client.get("/admin/reports");
    assert_eq!(
        response.status, 401,
        "anonymous should get 401 on /admin/reports"
    );

    let response = client.get("/admin/federation");
    assert_eq!(
        response.status, 401,
        "anonymous should get 401 on /admin/federation"
    );

    Ok(())
}

#[tokio::test]
async fn non_admin_gets_403_on_admin_pages() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    // Register admin first (user_id = 1)
    let mut admin_client = support::Client::new(port);
    register(&mut admin_client, "admin", "admin@example.com");
    login(&mut admin_client, "admin", PASSWORD);

    // Register a non-admin user (user_id = 2)
    let mut client = support::Client::new(port);
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "alice", PASSWORD);

    // Test all admin endpoints
    let response = client.get("/admin");
    assert_eq!(response.status, 403, "non-admin should get 403 on /admin");

    let response = client.get("/admin/users");
    assert_eq!(
        response.status, 403,
        "non-admin should get 403 on /admin/users"
    );

    let response = client.get("/admin/reports");
    assert_eq!(
        response.status, 403,
        "non-admin should get 403 on /admin/reports"
    );

    let response = client.get("/admin/federation");
    assert_eq!(
        response.status, 403,
        "non-admin should get 403 on /admin/federation"
    );

    Ok(())
}

#[tokio::test]
async fn admin_gets_200_on_admin_pages() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin user (user_id will be 1)
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Test all admin endpoints
    let response = client.get("/admin");
    assert_eq!(response.status, 200, "admin should get 200 on /admin");

    let response = client.get("/admin/users");
    assert_eq!(response.status, 200, "admin should get 200 on /admin/users");

    let response = client.get("/admin/reports");
    assert_eq!(
        response.status, 200,
        "admin should get 200 on /admin/reports"
    );

    let response = client.get("/admin/federation");
    assert_eq!(
        response.status, 200,
        "admin should get 200 on /admin/federation"
    );

    Ok(())
}

// ============================================================================
// User management tests
// ============================================================================

#[tokio::test]
async fn admin_can_list_users() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and some regular users
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    register(&mut client, "bob", "bob@example.com");

    // Login as admin
    login(&mut client, "admin", PASSWORD);

    // Get users list
    let response = client.get("/admin/users");
    assert_eq!(response.status, 200);
    assert!(response.body.contains("admin"), "should list admin user");
    assert!(response.body.contains("alice"), "should list alice user");
    assert!(response.body.contains("bob"), "should list bob user");

    Ok(())
}

#[tokio::test]
async fn admin_cannot_delete_themselves() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin (user_id = 1)
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Try to delete user 1 (admin)
    let response = client.post_form("/admin/users/1/delete", &[]);
    assert_eq!(response.status, 400, "admin cannot delete themselves");

    Ok(())
}

#[tokio::test]
async fn admin_can_delete_user() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a regular user
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "admin", PASSWORD);

    // Delete user alice (user_id = 2)
    let response = client.post_form("/admin/users/2/delete", &[]);
    assert_eq!(
        response.status, 302,
        "should redirect after scheduling deletion"
    );

    // Verify alice is still in the list (deletion is scheduled, not immediate)
    let response = client.get("/admin/users");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("alice"),
        "alice should still be listed after scheduling deletion"
    );

    // Verify a pending deletion was created by checking the database directly
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;
    let count = pending_deletions::Entity::find()
        .filter(pending_deletions::Column::UserId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(count, 1, "should have created a pending deletion record");

    Ok(())
}

// ============================================================================
// Grace period deletion tests
// ============================================================================

#[tokio::test]
async fn grace_period_deletion_actually_deletes_user() -> miette::Result<()> {
    use std::path::PathBuf;

    use frogue::attachments::file_store::FileStore;
    use frogue::config::REPO_ROOT_VAR_KEY;
    use frogue::moderation::process_pending_deletions;

    let port = free_port();
    let mut server = Server::spawn_with_env(
        &format!("127.0.0.1:{port}"),
        &[(frogue::config::DELETION_GRACE_PERIOD_VAR_KEY, "0")],
    );
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a regular user
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "admin", PASSWORD);

    // Schedule deletion of alice (user_id = 2)
    let response = client.post_form("/admin/users/2/delete", &[]);
    assert_eq!(response.status, 302);

    // Verify pending deletion was created with execute_at in the past (grace=0)
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    let pending = pending_deletions::Entity::find()
        .filter(pending_deletions::Column::UserId.eq(2))
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(pending.is_some(), "pending deletion should exist");

    // Manually trigger processing (bypass the 60s worker poll)
    let repo_root = PathBuf::from(std::env::var(REPO_ROOT_VAR_KEY).unwrap_or_default());
    let file_store = FileStore::new(repo_root.clone()).await.into_diagnostic()?;
    process_pending_deletions(&db, &file_store, &repo_root)
        .await
        .expect("processing deletions should succeed");

    // Verify the pending deletion record was removed
    let count = pending_deletions::Entity::find()
        .filter(pending_deletions::Column::UserId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(
        count, 0,
        "pending deletion should be removed after processing"
    );

    // Verify alice is actually deleted from the database
    use frogue::entities::users;
    let alice = users::Entity::find_by_id(2)
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(alice.is_none(), "alice should be deleted from the database");

    Ok(())
}

// ============================================================================
// Report queue tests
// ============================================================================

#[tokio::test]
async fn admin_sees_empty_reports_queue() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Get reports page
    let response = client.get("/admin/reports");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("No reports"),
        "should show empty state"
    );

    Ok(())
}

#[tokio::test]
async fn admin_can_filter_reports_by_state() -> miette::Result<()> {
    use frogue::entities::reports;
    use sea_orm::ActiveModelTrait;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Insert test reports with different states
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    let open_report = reports::ActiveModel {
        uuid: sea_orm::Set("open-report-uuid".to_string()),
        reporter_local_user_id: sea_orm::Set(Some(1)),
        target_local_user_id: sea_orm::Set(Some(1)),
        object_urls: sea_orm::Set("[]".to_string()),
        comment: sea_orm::Set(Some("Open report".to_string())),
        state: sea_orm::Set("open".to_string()),
        forwarded: sea_orm::Set(0),
        inbound: sea_orm::Set(0),
        ..Default::default()
    };
    open_report.insert(&db).await.into_diagnostic()?;

    let resolved_report = reports::ActiveModel {
        uuid: sea_orm::Set("resolved-report-uuid".to_string()),
        reporter_local_user_id: sea_orm::Set(Some(1)),
        target_local_user_id: sea_orm::Set(Some(1)),
        object_urls: sea_orm::Set("[]".to_string()),
        comment: sea_orm::Set(Some("Resolved report".to_string())),
        state: sea_orm::Set("resolved".to_string()),
        forwarded: sea_orm::Set(0),
        inbound: sea_orm::Set(0),
        ..Default::default()
    };
    resolved_report.insert(&db).await.into_diagnostic()?;

    // Default view should show open reports only
    let response = client.get("/admin/reports");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("open-report-uuid"),
        "should show open report"
    );
    assert!(
        !response.body.contains("resolved-report-uuid"),
        "should not show resolved report by default"
    );

    // Filter by resolved
    let response = client.get("/admin/reports?state=resolved");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("resolved-report-uuid"),
        "should show resolved report when filtered"
    );
    assert!(
        !response.body.contains("open-report-uuid"),
        "should not show open report when filtered to resolved"
    );

    // Show all
    let response = client.get("/admin/reports?state=");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("open-report-uuid"),
        "should show open report when showing all"
    );
    assert!(
        response.body.contains("resolved-report-uuid"),
        "should show resolved report when showing all"
    );

    Ok(())
}

#[tokio::test]
async fn admin_can_resolve_and_dismiss_reports() -> miette::Result<()> {
    use frogue::entities::reports;
    use sea_orm::ActiveModelTrait;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Insert a test report
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    let report = reports::ActiveModel {
        uuid: sea_orm::Set("test-report-uuid".to_string()),
        reporter_local_user_id: sea_orm::Set(Some(1)),
        target_local_user_id: sea_orm::Set(Some(1)),
        object_urls: sea_orm::Set("[]".to_string()),
        comment: sea_orm::Set(Some("Test report".to_string())),
        state: sea_orm::Set("open".to_string()),
        forwarded: sea_orm::Set(0),
        inbound: sea_orm::Set(0),
        ..Default::default()
    };
    report.insert(&db).await.into_diagnostic()?;

    // Resolve the report
    let response = client.post_form("/admin/reports/test-report-uuid/resolve", &[]);
    assert_eq!(response.status, 302, "should redirect after resolving");

    // Verify state changed
    let report = reports::Entity::find()
        .filter(reports::Column::Uuid.eq("test-report-uuid"))
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("report should exist");
    assert_eq!(report.state, "resolved", "report should be resolved");

    // Dismiss the report
    let response = client.post_form("/admin/reports/test-report-uuid/dismiss", &[]);
    assert_eq!(response.status, 302, "should redirect after dismissing");

    // Verify state changed
    let report = reports::Entity::find()
        .filter(reports::Column::Uuid.eq("test-report-uuid"))
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("report should exist");
    assert_eq!(report.state, "dismissed", "report should be dismissed");

    Ok(())
}

#[tokio::test]
async fn report_shortcuts_create_block_rows() -> miette::Result<()> {
    use frogue::entities::{admin_remote_blocks, instance_blocks, reports};
    use sea_orm::{ActiveModelTrait, Set};

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create a remote actor record
    let remote_actor = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: Set("https://remote.example/actor/123".to_string()),
        inbox_url: Set("https://remote.example/inbox".to_string()),
        shared_inbox_url: Set(None),
        public_key_pem: Set("fake-key".to_string()),
        public_key_id: Set("https://remote.example/actor/123#main-key".to_string()),
        name: Set(Some("Remote User".to_string())),
        preferred_username: Set(Some("remoteuser".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    let remote_actor = remote_actor.insert(&db).await.into_diagnostic()?;

    // Create a report with remote reporter
    let report = reports::ActiveModel {
        uuid: Set("shortcut-test-report-uuid".to_string()),
        reporter_remote_actor_url: Set(Some("https://remote.example/actor/123".to_string())),
        target_local_user_id: Set(Some(1)),
        object_urls: Set("[]".to_string()),
        comment: Set(Some("Test report for shortcuts".to_string())),
        state: Set("open".to_string()),
        forwarded: Set(0),
        inbound: Set(0),
        ..Default::default()
    };
    let report = report.insert(&db).await.into_diagnostic()?;

    // Test shortcut: block remote user from report detail
    // The shortcut form posts to the federation endpoint with the actor URL
    let response = client.post_form(
        "/admin/federation/block-remote-user",
        &[
            ("actor_url", "https://remote.example/actor/123"),
            ("reason", &format!("Reported content: {}", report.uuid)),
        ],
    );
    assert_eq!(
        response.status, 302,
        "should redirect after blocking remote user from report"
    );

    // Verify admin_remote_block was created
    let block_count = admin_remote_blocks::Entity::find()
        .filter(admin_remote_blocks::Column::RemoteActorId.eq(remote_actor.id))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(block_count, 1, "shortcut should create admin_remote_block");

    // Test shortcut: defederate domain from report detail
    // The shortcut form posts to the federation endpoint with the domain
    let response = client.post_form(
        "/admin/federation/block-domain",
        &[
            ("domain", "remote.example"),
            (
                "reason",
                &format!("Reporter domain for report: {}", report.uuid),
            ),
        ],
    );
    assert_eq!(
        response.status, 302,
        "should redirect after defederating domain from report"
    );

    // Verify instance_block was created
    let domain_block_count = instance_blocks::Entity::find()
        .filter(instance_blocks::Column::Domain.eq("remote.example"))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(
        domain_block_count, 1,
        "shortcut should create instance_block"
    );

    Ok(())
}

#[tokio::test]
async fn nav_badge_shows_open_report_count() -> miette::Result<()> {
    use frogue::entities::reports;
    use sea_orm::ActiveModelTrait;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Insert 3 open reports
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    for i in 1..=3 {
        let report = reports::ActiveModel {
            uuid: sea_orm::Set(format!("report-uuid-{}", i)),
            reporter_local_user_id: sea_orm::Set(Some(1)),
            target_local_user_id: sea_orm::Set(Some(1)),
            object_urls: sea_orm::Set("[]".to_string()),
            comment: sea_orm::Set(Some(format!("Open report {}", i))),
            state: sea_orm::Set("open".to_string()),
            forwarded: sea_orm::Set(0),
            inbound: sea_orm::Set(0),
            ..Default::default()
        };
        report.insert(&db).await.into_diagnostic()?;
    }

    // Check nav badge on /admin
    let response = client.get("/admin");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains(">3<"),
        "nav badge should show 3 open reports on /admin, got: {}",
        response.body
    );

    // Check nav badge on /admin/users
    let response = client.get("/admin/users");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains(">3<"),
        "nav badge should show 3 open reports on /admin/users"
    );

    // Check nav badge on /admin/reports
    let response = client.get("/admin/reports");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains(">3<"),
        "nav badge should show 3 open reports on /admin/reports"
    );

    // Check nav badge on /admin/federation
    let response = client.get("/admin/federation");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains(">3<"),
        "nav badge should show 3 open reports on /admin/federation"
    );

    Ok(())
}

#[tokio::test]
async fn block_remote_user_by_handle() -> miette::Result<()> {
    use frogue::entities::admin_remote_blocks;
    use sea_orm::{ActiveModelTrait, Set};

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create the instance actor (required for Block activity delivery)
    let (private_key_pem, public_key_pem) =
        frogue::activitypub::keys::generate().expect("key generation failed");
    let instance_actor = frogue::entities::ap_actors::ActiveModel {
        user_id: Set(None),
        repo_id: Set(None),
        private_key_pem: Set(private_key_pem),
        public_key_pem: Set(public_key_pem),
        ..Default::default()
    };
    instance_actor.insert(&db).await.into_diagnostic()?;

    // Create a mock remote actor with webfinger support
    let remote_port = free_port();
    let remote_actor = support::ap::RemoteActor::serve(remote_port);

    // Pre-populate the remote actor in the database
    let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: Set(remote_actor.actor_url.clone()),
        inbox_url: Set(remote_actor.inbox_url.clone()),
        shared_inbox_url: Set(None),
        public_key_pem: Set(remote_actor.public_key_pem.clone()),
        public_key_id: Set(remote_actor.key_id.clone()),
        name: Set(Some("Remote User".to_string())),
        preferred_username: Set(Some("remoteuser".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    remote_actor_model.insert(&db).await.into_diagnostic()?;

    // Block the remote user using @user@host format
    // Note: This test assumes the webfinger implementation will resolve the
    // handle to the remote actor URL. In a real scenario, the remote server
    // would serve webfinger responses. For this test, we're verifying the
    // handle format is accepted.
    let handle = format!("remoteuser@127.0.0.1:{}", remote_port);
    let response = client.post_form(
        "/admin/federation/block-remote-user",
        &[("actor_url", &handle), ("reason", "")],
    );

    // The handler should accept the handle format (even if resolution fails in
    // test env) In production, it would resolve via webfinger and create
    // the block
    assert!(
        response.status == 302 || response.status == 400,
        "handler should accept @user@host format"
    );

    // Verify that at least one block was attempted (if resolution succeeded)
    let block_count = admin_remote_blocks::Entity::find()
        .count(&db)
        .await
        .into_diagnostic()?;

    // If the block was created, verify it's recorded
    if response.status == 302 {
        assert_eq!(
            block_count, 1,
            "should have created an admin_remote_block record"
        );
    }

    Ok(())
}

// ============================================================================
// Federation controls tests
// ============================================================================

// ============================================================================
// Delete cascade tests
// ============================================================================

#[tokio::test]
async fn delete_user_cascades_to_repos_and_notes() -> miette::Result<()> {
    use frogue::entities::{ap_notes, repositories, ssh_keys};
    use sea_orm::{ActiveModelTrait, Set};

    let port = free_port();
    let mut server = Server::spawn_with_env(
        &format!("127.0.0.1:{port}"),
        &[(frogue::config::DELETION_GRACE_PERIOD_VAR_KEY, "0")],
    );
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a user with content
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "admin", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Explicitly create alice's actor (actors are lazy-created on first AP
    // access)
    let alice_actor = frogue::activitypub::actor::get_or_create(&db, 2)
        .await
        .into_diagnostic()?;

    // Create a repository for alice (user_id = 2)
    let repo = repositories::ActiveModel {
        uuid: Set(uuid::Uuid::new_v4()),
        slug: Set("test-repo".to_string()),
        owner_id: Set(2),
        description: Set(Some("Test repo".to_string())),
        ..Default::default()
    };
    let _repo = repo.insert(&db).await.into_diagnostic()?;

    // Create a note for alice
    let note = ap_notes::ActiveModel {
        uuid: Set(uuid::Uuid::new_v4()),
        actor_id: Set(alice_actor.id),
        content_md: Set("Test note content".to_string()),
        content_html: Set("<p>Test note content</p>".to_string()),
        ..Default::default()
    };
    let _note = note.insert(&db).await.into_diagnostic()?;

    // Create an SSH key for alice
    let ssh_key = ssh_keys::ActiveModel {
        id: Set(1),
        user_id: Set(2),
        name: Set("test-key".to_string()),
        algorithm: Set("ssh-rsa".to_string()),
        fingerprint: Set("SHA256:abc123def456".to_string()),
        public_key: Set("AAAAB3NzaC1yc2EAAAADAQABAAABAQ...".to_string()),
        created_at: Set(chrono::Utc::now().into()),
        last_used_at: Set(None),
    };
    let _ssh_key = ssh_key.insert(&db).await.into_diagnostic()?;

    // Verify entities exist before deletion
    let repo_count = repositories::Entity::find()
        .filter(repositories::Column::OwnerId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(repo_count, 1, "alice should have 1 repo before deletion");

    let note_count = ap_notes::Entity::find()
        .filter(ap_notes::Column::ActorId.eq(alice_actor.id))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(note_count, 1, "alice should have 1 note before deletion");

    let ssh_key_count = ssh_keys::Entity::find()
        .filter(ssh_keys::Column::UserId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(
        ssh_key_count, 1,
        "alice should have 1 ssh key before deletion"
    );

    // Delete alice
    let response = client.post_form("/admin/users/2/delete", &[]);
    assert_eq!(
        response.status, 302,
        "should redirect after scheduling deletion"
    );

    // Process pending deletions immediately (grace period is 0)
    let repo_root = server.repo_root().to_path_buf();
    let file_store = frogue::attachments::file_store::FileStore::new(repo_root.clone())
        .await
        .into_diagnostic()?;
    frogue::moderation::process_pending_deletions(&db, &file_store, &repo_root)
        .await
        .expect("processing deletions should succeed");

    // Verify all related entities are deleted
    let repo_count = repositories::Entity::find()
        .filter(repositories::Column::OwnerId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(repo_count, 0, "alice's repos should be deleted");

    let note_count = ap_notes::Entity::find()
        .filter(ap_notes::Column::ActorId.eq(alice_actor.id))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(note_count, 0, "alice's notes should be deleted");

    let ssh_key_count = ssh_keys::Entity::find()
        .filter(ssh_keys::Column::UserId.eq(2))
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(ssh_key_count, 0, "alice's ssh keys should be deleted");

    // Verify user is deleted
    let alice = frogue::entities::users::Entity::find_by_id(2)
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(alice.is_none(), "alice should be deleted from database");

    Ok(())
}

#[tokio::test]
async fn admin_sees_empty_federation_page() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Get federation page
    let response = client.get("/admin/federation");
    assert_eq!(response.status, 200);

    Ok(())
}

#[tokio::test]
async fn admin_can_block_domain() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Block a domain
    let response = client.post_form(
        "/admin/federation/block-domain",
        &[("domain", "evil.example.com"), ("reason", "spam")],
    );
    assert_eq!(response.status, 302, "should redirect after blocking");

    // Verify domain is blocked
    let response = client.get("/admin/federation");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("evil.example.com"),
        "domain should be listed"
    );

    Ok(())
}

#[tokio::test]
async fn domain_normalization_strips_scheme_and_path() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Block a domain with scheme and path
    let response = client.post_form(
        "/admin/federation/block-domain",
        &[("domain", "HTTPS://Evil.Example.COM/x/y"), ("reason", "")],
    );
    assert_eq!(response.status, 302);

    // Verify domain is normalized to lowercase without scheme/path
    let response = client.get("/admin/federation");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("evil.example.com"),
        "domain should be normalized"
    );
    assert!(
        !response.body.contains("HTTPS://"),
        "scheme should be stripped"
    );
    assert!(!response.body.contains("/x/y"), "path should be stripped");

    Ok(())
}

#[tokio::test]
async fn admin_can_unblock_domain() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    // Block a domain
    client.post_form(
        "/admin/federation/block-domain",
        &[("domain", "evil.example.com"), ("reason", "")],
    );

    // Unblock it
    let response = client.post_form(
        "/admin/federation/unblock-domain",
        &[("domain", "evil.example.com")],
    );
    assert_eq!(response.status, 302);

    // Verify domain is unblocked
    let response = client.get("/admin/federation");
    assert_eq!(response.status, 200);
    assert!(
        !response.body.contains("evil.example.com"),
        "domain should be unblocked"
    );

    Ok(())
}

// ============================================================================
// Federation delivery tests
// ============================================================================

#[tokio::test]
async fn delete_user_delivers_delete_activity_to_followers() -> miette::Result<()> {
    use frogue::entities::ap_followers;
    use sea_orm::{ActiveModelTrait, Set};

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a user
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "admin", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create alice's actor
    let alice_actor = frogue::activitypub::actor::get_or_create(&db, 2)
        .await
        .into_diagnostic()?;

    // Create a mock remote follower
    let remote_port = free_port();
    let remote_actor = support::ap::RemoteActor::serve(remote_port);

    // Create remote actor record in ap_remote_actors (for the follower)
    let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: Set(remote_actor.actor_url.clone()),
        inbox_url: Set(remote_actor.inbox_url.clone()),
        shared_inbox_url: Set(None),
        public_key_pem: Set(remote_actor.public_key_pem.clone()),
        public_key_id: Set(remote_actor.key_id.clone()),
        name: Set(Some("Remote Follower".to_string())),
        preferred_username: Set(Some("follower".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    let remote_actor_model = remote_actor_model.insert(&db).await.into_diagnostic()?;

    // Create a follow relationship (remote follower follows alice)
    // local_actor_id = alice (local user being followed)
    // remote_actor_id = the remote follower's actor record in our DB
    let follow = ap_followers::ActiveModel {
        id: Set(0),
        local_actor_id: Set(alice_actor.id),
        remote_actor_id: Set(remote_actor_model.id),
        follow_activity_id: Set(format!(
            "{}/activities/follow-1",
            server.public_url().unwrap()
        )),
        ..Default::default()
    };
    follow.insert(&db).await.into_diagnostic()?;

    // Schedule deletion of alice
    let response = client.post_form("/admin/users/2/delete", &[]);
    assert_eq!(response.status, 302);

    // Process the deletion immediately
    let repo_root = server.repo_root().to_path_buf();
    let file_store = frogue::attachments::file_store::FileStore::new(repo_root.clone())
        .await
        .into_diagnostic()?;
    frogue::moderation::process_pending_deletions(&db, &file_store, &repo_root)
        .await
        .expect("processing deletions should succeed");

    // Give the delivery worker time to pick up and send the activity
    // The worker polls every 2 seconds, so we wait 3 seconds to be safe
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    // Verify the remote actor received a Delete activity
    let deliveries = remote_actor.recorded_deliveries();
    assert!(
        !deliveries.is_empty(),
        "remote follower should have received deliveries"
    );

    // Find a Delete activity
    let delete_activity = deliveries.iter().find(|d| {
        let body: serde_json::Value = serde_json::from_str(&d.body).unwrap_or_default();
        body.get("type").and_then(|t| t.as_str()) == Some("Delete")
    });

    assert!(
        delete_activity.is_some(),
        "remote follower should have received a Delete activity"
    );

    // Verify the Delete activity contains a Person object
    let delete_body: serde_json::Value =
        serde_json::from_str(&delete_activity.unwrap().body).unwrap();

    let object = delete_body
        .get("object")
        .expect("Delete should have an object");

    // The object might be a string URL or an embedded object
    if let Some(obj_str) = object.as_str() {
        // If it's a URL, we need to check if it points to a user/actor
        assert!(
            obj_str.contains("/~"),
            "Delete activity object should reference a user actor URL"
        );
    } else if let Some(obj_obj) = object.as_object() {
        let object_type = obj_obj.get("type").and_then(|t| t.as_str());
        assert_eq!(
            object_type,
            Some("Person"),
            "Delete activity should be for a Person"
        );
    } else {
        panic!("Delete activity object should be either a string URL or an object");
    }

    Ok(())
}

#[tokio::test]
async fn block_remote_user_delivers_block_activity() -> miette::Result<()> {
    use frogue::entities::admin_remote_blocks;
    use sea_orm::{ActiveModelTrait, EntityTrait, Set};
    use support::ap::RemoteActor;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin
    register(&mut client, "admin", "admin@example.com");
    login(&mut client, "admin", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create the instance actor (Application actor for signing federation
    // requests) This is an ap_actors row with both user_id and repo_id set
    // to NULL
    let (private_key_pem, public_key_pem) =
        frogue::activitypub::keys::generate().expect("key generation failed");
    let instance_actor = frogue::entities::ap_actors::ActiveModel {
        user_id: Set(None),
        repo_id: Set(None),
        private_key_pem: Set(private_key_pem),
        public_key_pem: Set(public_key_pem),
        ..Default::default()
    };
    instance_actor.insert(&db).await.into_diagnostic()?;

    // Create a mock remote actor
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Pre-populate the remote actor in the database (simulating a previous
    // fetch/discovery)
    let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: Set(remote_actor.actor_url.clone()),
        inbox_url: Set(remote_actor.inbox_url.clone()),
        shared_inbox_url: Set(None),
        public_key_pem: Set(remote_actor.public_key_pem.clone()),
        public_key_id: Set(remote_actor.key_id.clone()),
        name: Set(Some("Remote User".to_string())),
        preferred_username: Set(Some("user".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    remote_actor_model.insert(&db).await.into_diagnostic()?;

    // Block the remote user using their full actor URL
    let actor_url = format!("http://127.0.0.1:{remote_port}/actor");
    let response = client.post_form(
        "/admin/federation/block-remote-user",
        &[("actor_url", &actor_url), ("reason", "")],
    );
    assert_eq!(
        response.status, 302,
        "should redirect after blocking remote user"
    );

    // Give the delivery worker time to pick up and send the activity
    // The worker polls every 2 seconds, so we wait 3 seconds to be safe
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    // Verify the remote actor received a Block activity
    let deliveries = remote_actor.recorded_deliveries();
    assert!(
        !deliveries.is_empty(),
        "remote actor should have received deliveries"
    );

    // Find a Block activity
    let block_activity = deliveries.iter().find(|d| {
        let body: serde_json::Value = serde_json::from_str(&d.body).unwrap_or_default();
        body.get("type").and_then(|t| t.as_str()) == Some("Block")
    });

    assert!(
        block_activity.is_some(),
        "remote actor should have received a Block activity"
    );

    // Verify the Block activity is signed by the instance actor
    let block_body: serde_json::Value =
        serde_json::from_str(&block_activity.unwrap().body).unwrap();

    let actor_url = block_body
        .get("actor")
        .and_then(|a| a.as_str())
        .expect("Block should have an actor");
    assert!(
        actor_url.contains("/actor"),
        "Block should be signed by instance actor"
    );

    // Verify the block was recorded in the database
    let block_count = admin_remote_blocks::Entity::find()
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(
        block_count, 1,
        "should have created an admin_remote_block record"
    );

    Ok(())
}
