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

//! Tests for instance defederation and admin remote-user blocks.
//!
//! These tests verify that:
//! - Instance-wide domain blocks reject inbox deliveries (403) and accept again
//!   after unblocking (202)
//! - Blocked domains are refused during key resolution before any actor fetch
//! - Domain blocks normalize host:port input to the bare host
//! - Instance-wide domain blocks prevent outbound deliveries
//! - Instance-wide domain blocks hide content and reaction counts from feeds
//! - Local follows of blocked handles fail with a visible message
//! - Admin single-user blocks reject that user's deliveries
//! - Admin single-user blocks allow same-domain peers
//! - Blocking a remote user delivers an instance-actor-signed `Block`

use std::time::Duration;

use frogue::entities::{admin_remote_blocks, ap_remote_actors, instance_blocks};
use miette::IntoDiagnostic;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter};
use support::{Client, Server};

mod support;

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

fn login(client: &mut 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
    );
}

#[tokio::test]
async fn domain_block_rejects_inbox_delivery() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    // Create admin user (first user is admin)
    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

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

    // Pre-populate the remote actor in the database
    let remote_actor_model = 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("remote".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()?;

    // Extract domain from actor URL
    let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
    let domain = actor_url_parsed
        .host_str()
        .expect("actor URL should have host");

    // Admin blocks the domain, supplying a host:port pair. The port must be
    // stripped so the block matches actor URLs on the host.
    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[
            ("domain", &format!("{domain}:{remote_port}")),
            ("reason", "test block"),
        ],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect, got {}",
        response.status
    );

    // Verify the instance block row exists, normalized to the bare host
    let block = instance_blocks::Entity::find()
        .filter(instance_blocks::Column::Domain.eq(domain))
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(block.is_some(), "instance block row should exist");

    // Now try to send a signed activity from the blocked domain to our inbox
    // The inbox should reject it with 403
    let instance_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");

    // Create a simple Follow activity signed by the blocked remote actor
    let follow_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/follow-1", remote_actor.actor_url),
        "actor": remote_actor.actor_url,
        "object": format!("http://127.0.0.1:{port}/~admin")
    });

    let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
    assert_eq!(
        status, 403,
        "inbox should reject deliveries from a blocked domain"
    );

    use frogue::entities::ap_followers;
    let follow = ap_followers::Entity::find()
        .filter(ap_followers::Column::RemoteActorId.eq(_remote_actor_model.id))
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(
        follow.is_none(),
        "follow from blocked domain should be rejected"
    );

    // Unblocking the domain restores federation: the same delivery is accepted.
    let response = admin_client.post_form(
        "/admin/federation/unblock-domain",
        &[("domain", &format!("{domain}:{remote_port}"))],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance unblock should redirect"
    );

    let follow_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/follow-2", remote_actor.actor_url),
        "actor": remote_actor.actor_url,
        "object": format!("http://127.0.0.1:{port}/~admin")
    });

    let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
    assert_eq!(status, 202, "inbox should accept deliveries after unblock");

    Ok(())
}

#[tokio::test]
async fn blocked_domain_is_refused_before_actor_fetch() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    let remote_port = support::free_port();
    let remote_actor = RemoteActor::serve(remote_port);
    let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
    let domain = actor_url_parsed
        .host_str()
        .expect("actor URL should have host");

    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[("domain", domain), ("reason", "test block")],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    // The actor is NOT cached. Resolving its signing key must refuse before
    // any actor document fetch.
    let instance_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");
    let follow_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/follow-uncached", remote_actor.actor_url),
        "actor": remote_actor.actor_url,
        "object": format!("http://127.0.0.1:{port}/~admin")
    });

    let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
    assert_eq!(
        status, 403,
        "uncached actor on a blocked domain should be refused"
    );
    assert!(
        remote_actor.recorded_get_requests().is_empty(),
        "blocked domains must not be fetched during key resolution"
    );

    Ok(())
}

#[tokio::test]
async fn local_follow_by_handle_of_blocked_domain_reports_block() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

    // Keep a remote actor around to obtain a real port for the handle. It does
    // not need to be reachable: the block is checked before webfinger.
    let remote_port = support::free_port();
    let _remote_actor = RemoteActor::serve(remote_port);

    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    let mut alice_client = Client::new(port);
    alice_client.post_form(
        "/register",
        &[
            ("name", "alice"),
            ("email", "alice@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut alice_client, "alice", PASSWORD);

    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[("domain", "127.0.0.1"), ("reason", "test block")],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    let handle = format!("@bob@127.0.0.1:{remote_port}");
    let response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(response.status, 303, "follow should redirect");
    let location = response
        .header("location")
        .expect("redirect should have location");
    assert!(
        location.contains("blocked"),
        "redirect should mention the block, got {location}"
    );

    Ok(())
}

#[tokio::test]
async fn domain_block_prevents_outbound_delivery() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    // Create admin user
    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    // Create a regular user
    let mut user_client = Client::new(port);
    user_client.post_form(
        "/register",
        &[
            ("name", "alice"),
            ("email", "alice@example.com"),
            ("password", PASSWORD),
        ],
    );

    // Create a remote actor on a domain we'll block
    let remote_port = support::free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    let remote_actor_model = 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("remote".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()?;

    let alice_id = {
        use frogue::entities::users;
        users::Entity::find()
            .filter(users::Column::Name.eq("alice"))
            .one(&db)
            .await
            .into_diagnostic()?
            .expect("alice should exist")
            .id
    };
    let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
        .await
        .into_diagnostic()?;

    // Extract domain and block it first, so the worker cannot race the block
    let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
    let domain = actor_url_parsed
        .host_str()
        .expect("actor URL should have host");

    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[("domain", domain), ("reason", "test block")],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    // Enqueue a delivery to the now-blocked inbox.
    let activity_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/pending-follow", remote_actor.actor_url),
        "actor": format!("http://127.0.0.1:{port}/~alice"),
        "object": remote_actor.actor_url
    })
    .to_string();
    let activity_id = frogue::activitypub::delivery::enqueue_delivery(
        &db,
        alice_actor.id,
        uuid::Uuid::new_v4(),
        activity_json,
        remote_actor.actor_url.clone(),
        remote_actor.inbox_url.clone(),
    )
    .await
    .expect("enqueueing a delivery should succeed");

    // The worker polls every two seconds. Wait until it marks the delivery
    // failed without contacting the blocked inbox.
    let deadline = std::time::Instant::now() + Duration::from_secs(10);
    loop {
        use frogue::entities::ap_deliveries;
        let delivery = ap_deliveries::Entity::find()
            .filter(ap_deliveries::Column::ActivityId.eq(activity_id))
            .one(&db)
            .await
            .into_diagnostic()?;
        if let Some(delivery) = delivery
            && delivery.status == "failed"
        {
            assert_eq!(
                delivery.last_error.as_deref(),
                Some("domain blocked"),
                "blocked delivery should record why it was dropped"
            );
            break;
        }
        if std::time::Instant::now() >= deadline {
            miette::bail!("delivery to blocked domain was not dropped in time");
        }
        tokio::time::sleep(Duration::from_millis(200)).await;
    }

    // Check that no delivery was actually sent to the blocked domain
    let deliveries = remote_actor.recorded_deliveries();
    assert_eq!(
        deliveries.len(),
        0,
        "no deliveries should be sent to blocked domain"
    );

    Ok(())
}

#[tokio::test]
async fn domain_block_hides_content_from_feed() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    // Create admin user
    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    // Create alice
    let mut alice_client = Client::new(port);
    alice_client.post_form(
        "/register",
        &[
            ("name", "alice"),
            ("email", "alice@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut alice_client, "alice", PASSWORD);

    let alice_id = {
        use frogue::entities::users;
        users::Entity::find()
            .filter(users::Column::Name.eq("alice"))
            .one(&db)
            .await
            .into_diagnostic()?
            .expect("alice should exist")
            .id
    };

    let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
        .await
        .into_diagnostic()?;

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

    let remote_actor_model = 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("remote".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()?;

    // Alice follows the remote actor
    let follow_model = frogue::entities::ap_following::ActiveModel {
        local_actor_id: Set(alice_actor.id),
        remote_actor_id: Set(remote_actor_model.id),
        follow_activity_id: Set(format!(
            "https://example.com/follow/{}",
            uuid::Uuid::new_v4()
        )),
        state: Set("accepted".to_string()),
        ..Default::default()
    };
    follow_model.insert(&db).await.into_diagnostic()?;

    // Create a remote note
    use frogue::entities::ap_remote_notes;
    let note_url = format!("{}/notes/{}", remote_actor.actor_url, uuid::Uuid::new_v4());
    let note = ap_remote_notes::ActiveModel {
        remote_actor_id: Set(remote_actor_model.id),
        object_url: Set(note_url.clone()),
        content_html: Set("<p>Test note from blocked domain</p>".to_string()),
        published: Set(chrono::Utc::now().into()),
        received_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    let note = note.insert(&db).await.into_diagnostic()?;

    // Verify note appears in feed before blocking
    let feed_before = frogue::activitypub::feed::get_home_feed(
        &db,
        alice_id,
        &server.database_url().replace("sqlite://", "http://"),
        20,
        0,
    )
    .await
    .unwrap();

    assert!(
        feed_before.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == &note.id)),
        "note should appear in feed before domain block"
    );

    // Extract domain and block it
    let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
    let domain = actor_url_parsed
        .host_str()
        .expect("actor URL should have host");

    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[("domain", domain), ("reason", "test block")],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    // Verify note is hidden from feed after domain block
    let feed_after = frogue::activitypub::feed::get_home_feed(
        &db,
        alice_id,
        &server.database_url().replace("sqlite://", "http://"),
        20,
        0,
    )
    .await
    .unwrap();

    assert!(
        !feed_after.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == &note.id)),
        "note should be hidden from feed after domain block"
    );

    // Unblock the domain
    let response =
        admin_client.post_form("/admin/federation/unblock-domain", &[("domain", domain)]);
    assert!(
        response.status == 302 || response.status == 303,
        "instance unblock should redirect"
    );

    // Verify note reappears in feed
    let feed_restored = frogue::activitypub::feed::get_home_feed(
        &db,
        alice_id,
        &server.database_url().replace("sqlite://", "http://"),
        20,
        0,
    )
    .await
    .unwrap();

    assert!(
        feed_restored.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == &note.id)),
        "note should reappear in feed after unblocking domain"
    );

    Ok(())
}

#[tokio::test]
async fn admin_user_block_rejects_only_that_user() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    // Create admin user
    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    // Create two remote actors on different ports (simulating same domain)
    let remote_port1 = support::free_port();
    let remote_actor1 = RemoteActor::serve(remote_port1);

    let remote_port2 = support::free_port();
    let remote_actor2 = RemoteActor::serve(remote_port2);

    let remote_actor1_model = ap_remote_actors::ActiveModel {
        actor_url: Set(remote_actor1.actor_url.clone()),
        inbox_url: Set(remote_actor1.inbox_url.clone()),
        shared_inbox_url: Set(None),
        public_key_pem: Set(remote_actor1.public_key_pem.clone()),
        public_key_id: Set(remote_actor1.key_id.clone()),
        name: Set(Some("Remote User 1".to_string())),
        preferred_username: Set(Some("remote1".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    let remote_actor1_model = remote_actor1_model.insert(&db).await.into_diagnostic()?;

    let remote_actor2_model = ap_remote_actors::ActiveModel {
        actor_url: Set(remote_actor2.actor_url.clone()),
        inbox_url: Set(remote_actor2.inbox_url.clone()),
        shared_inbox_url: Set(None),
        public_key_pem: Set(remote_actor2.public_key_pem.clone()),
        public_key_id: Set(remote_actor2.key_id.clone()),
        name: Set(Some("Remote User 2".to_string())),
        preferred_username: Set(Some("remote2".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    let remote_actor2_model = remote_actor2_model.insert(&db).await.into_diagnostic()?;

    // Admin blocks only the first user
    let response = admin_client.post_form(
        "/admin/federation/block-remote-user",
        &[
            ("actor_url", &remote_actor1.actor_url),
            ("reason", "test block"),
        ],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "admin block should redirect"
    );

    // Verify the admin block row exists
    let block = admin_remote_blocks::Entity::find()
        .filter(admin_remote_blocks::Column::RemoteActorId.eq(remote_actor1_model.id))
        .one(&db)
        .await
        .into_diagnostic()?;
    assert!(block.is_some(), "admin block row should exist");

    // Now try to send activities from both actors to our inbox
    // Use the user inbox for admin (the actual inbox route)
    let admin_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");

    // First actor should be rejected with 403
    let follow_activity1 = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/follow-1", remote_actor1.actor_url),
        "actor": remote_actor1.actor_url,
        "object": format!("http://127.0.0.1:{port}/~admin")
    });

    let status1 = remote_actor1.send_activity_with_status(&admin_inbox_url, follow_activity1);
    assert_eq!(status1, 403, "blocked user's delivery should be rejected");

    // Check that no follow was created for the blocked user
    use frogue::entities::ap_followers;
    let follow1 = ap_followers::Entity::find()
        .filter(ap_followers::Column::RemoteActorId.eq(remote_actor1_model.id))
        .one(&db)
        .await
        .into_diagnostic()?;

    assert!(
        follow1.is_none(),
        "blocked user's follow should be rejected"
    );

    // Second actor should be accepted (same host, different actor)
    let follow_activity2 = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "id": format!("{}/activities/follow-2", remote_actor2.actor_url),
        "actor": remote_actor2.actor_url,
        "object": format!("http://127.0.0.1:{port}/~admin")
    });

    let status2 = remote_actor2.send_activity_with_status(&admin_inbox_url, follow_activity2);
    assert_eq!(status2, 202, "same-domain peer should still federate");

    // Check that follow was created for the non-blocked user
    let follow2 = ap_followers::Entity::find()
        .filter(ap_followers::Column::RemoteActorId.eq(remote_actor2_model.id))
        .one(&db)
        .await
        .into_diagnostic()?;

    assert!(
        follow2.is_some(),
        "non-blocked user's follow should be accepted"
    );

    Ok(())
}

#[tokio::test]
async fn local_follow_of_blocked_actor_rejected() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    // Create admin user
    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    // Create alice
    let mut alice_client = Client::new(port);
    alice_client.post_form(
        "/register",
        &[
            ("name", "alice"),
            ("email", "alice@example.com"),
            ("password", PASSWORD),
        ],
    );

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

    let remote_actor_model = 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("remote".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()?;

    // Extract domain and block it
    let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
    let domain = actor_url_parsed
        .host_str()
        .expect("actor URL should have host");

    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[("domain", domain), ("reason", "test block")],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    // Alice tries to follow the blocked actor
    login(&mut alice_client, "alice", PASSWORD);
    let response =
        alice_client.post_form("/~alice/follow", &[("actor_url", &remote_actor.actor_url)]);

    assert_eq!(
        response.status, 303,
        "follow of a blocked actor should redirect with an error"
    );
    let location = response
        .header("location")
        .expect("redirect should have location");
    assert!(
        location.contains("blocked"),
        "redirect should mention the block, got {location}"
    );

    Ok(())
}

#[tokio::test]
async fn admin_user_block_delivers_instance_actor_block() -> miette::Result<()> {
    use support::ap::RemoteActor;

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

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

    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    let remote_port = support::free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    let remote_actor_model = 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("remote".to_string())),
        icon_url: Set(None),
        fetched_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    remote_actor_model.insert(&db).await.into_diagnostic()?;

    let response = admin_client.post_form(
        "/admin/federation/block-remote-user",
        &[
            ("actor_url", &remote_actor.actor_url),
            ("reason", "test block"),
        ],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "admin block should redirect"
    );

    // The delivery worker polls every two seconds. Wait for the Block activity
    // signed by the instance actor to reach the remote inbox.
    let instance_actor_url = format!("http://127.0.0.1:{port}/actor");
    let deadline = std::time::Instant::now() + Duration::from_secs(10);
    loop {
        let delivered = remote_actor.recorded_deliveries().iter().any(|delivery| {
            let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) else {
                return false;
            };
            activity["type"] == "Block" && activity["actor"] == instance_actor_url
        });
        if delivered {
            return Ok(());
        }
        if std::time::Instant::now() >= deadline {
            break;
        }
        tokio::time::sleep(Duration::from_millis(200)).await;
    }

    miette::bail!(
        "instance actor Block was not delivered to the blocked remote user; got {:?}",
        remote_actor
            .recorded_deliveries()
            .iter()
            .map(|delivery| delivery.body.clone())
            .collect::<Vec<_>>()
    );
}

#[tokio::test]
async fn domain_block_hides_reaction_counts() -> miette::Result<()> {
    use frogue::entities::{ap_notes, ap_reactions, users};
    use support::ap::RemoteActor;

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

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

    let mut admin_client = Client::new(port);
    admin_client.post_form(
        "/register",
        &[
            ("name", "admin"),
            ("email", "admin@example.com"),
            ("password", PASSWORD),
        ],
    );
    login(&mut admin_client, "admin", PASSWORD);

    let mut alice_client = Client::new(port);
    alice_client.post_form(
        "/register",
        &[
            ("name", "alice"),
            ("email", "alice@example.com"),
            ("password", PASSWORD),
        ],
    );

    let alice_id = users::Entity::find()
        .filter(users::Column::Name.eq("alice"))
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("alice should exist")
        .id;
    let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
        .await
        .into_diagnostic()?;

    let remote_port = support::free_port();
    let remote_actor = RemoteActor::serve(remote_port);
    let remote_actor_model = 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("remote".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()?;

    let note = ap_notes::ActiveModel {
        uuid: Set(uuid::Uuid::new_v4()),
        actor_id: Set(alice_actor.id),
        content_md: Set("hello".to_string()),
        content_html: Set("<p>hello</p>".to_string()),
        created_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    }
    .insert(&db)
    .await
    .into_diagnostic()?;

    ap_reactions::ActiveModel {
        note_id: Set(Some(note.id)),
        kind: Set("like".to_string()),
        remote_actor_id: Set(Some(remote_actor_model.id)),
        activity_id: Set(Some(format!("{}/likes/1", remote_actor.actor_url))),
        created_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    }
    .insert(&db)
    .await
    .into_diagnostic()?;

    // A local reaction has no remote actor and must survive actor filtering.
    ap_reactions::ActiveModel {
        note_id: Set(Some(note.id)),
        kind: Set("like".to_string()),
        local_user_id: Set(Some(alice_id)),
        created_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    }
    .insert(&db)
    .await
    .into_diagnostic()?;

    let before = frogue::activitypub::notes::get_reactions(&db, note.id, Some(alice_id))
        .await
        .into_diagnostic()?;
    assert_eq!(
        before.likes, 2,
        "local and remote reactions should be visible before the block"
    );

    let domain = url::Url::parse(&remote_actor.actor_url)
        .into_diagnostic()?
        .host_str()
        .expect("actor URL should have host")
        .to_string();
    let response = admin_client.post_form(
        "/admin/federation/block-domain",
        &[
            ("domain", &format!("{domain}:{remote_port}")),
            ("reason", "test block"),
        ],
    );
    assert!(
        response.status == 302 || response.status == 303,
        "instance block should redirect"
    );

    let during = frogue::activitypub::notes::get_reactions(&db, note.id, Some(alice_id))
        .await
        .into_diagnostic()?;
    assert_eq!(
        during.likes, 1,
        "only the local reaction should remain visible after the block"
    );

    let anonymous = frogue::activitypub::notes::get_reactions(&db, note.id, None)
        .await
        .into_diagnostic()?;
    assert_eq!(
        anonymous.likes, 1,
        "the local reaction must remain visible to anonymous viewers too"
    );

    Ok(())
}
