//! Tests for ForgeFed ticket and patch federation document serving.

mod support;

use std::fs;
use std::path::Path;
use std::process::{Command, Output};

use support::{Server, free_port};

/// Register a user through the web form.
fn register(client: &mut support::Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[
            ("name", name),
            ("email", &email),
            ("password", "correct horse battery staple"),
        ],
    );
    assert_eq!(
        response.status, 303,
        "registering should redirect: {}",
        response.body
    );
}

/// Create a repository through the web form.
fn create_repository(client: &mut support::Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "A test repository"),
            ("visibility", visibility),
        ],
    );
    assert_eq!(response.status, 303, "creating repository should redirect");
}

/// Create a ticket through the web form.
fn create_ticket(
    client: &mut support::Client,
    owner: &str,
    repo: &str,
    summary: &str,
    description: &str,
) {
    let response = client.post_form(
        &format!("/~{owner}/{repo}/tickets"),
        &[("summary", summary), ("description", description)],
    );
    assert_eq!(response.status, 303, "creating ticket should redirect");
}

/// Create a ticket via remote Offer activity.
/// Returns the ticket URL.
fn create_ticket_via_offer(
    _client: &mut support::Client,
    port: u16,
    owner: &str,
    repo: &str,
    summary: &str,
    description: &str,
    remote: &support::ap::RemoteActor,
) -> String {
    let repo_url = format!("http://127.0.0.1:{port}/~{owner}/{repo}");
    let inbox_url = format!("{repo_url}/inbox");

    let offer = serde_json::json!({
        "@context": ["https://www.w3.org/ns/activitystreams", "https://forgefed.org/ns"],
        "type": "Offer",
        "id": format!("{}/offers/{}", remote.actor_url, uuid::Uuid::new_v4()),
        "actor": remote.actor_url,
        "to": [repo_url],
        "object": {
            "type": "Ticket",
            "id": format!("{}/tickets/{}", remote.actor_url, uuid::Uuid::new_v4()),
            "attributedTo": remote.actor_url,
            "summary": summary,
            "content": description,
        },
    });

    remote.send_activity(&inbox_url, offer);
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Return the ticket URL (assuming it's ticket #1)
    format!("{repo_url}/tickets/1")
}

/// Fetch a ticket document with ActivityPub content negotiation.
fn fetch_ticket_ap(
    client: &mut support::Client,
    owner: &str,
    repo: &str,
    number: i64,
) -> serde_json::Value {
    let response = client.get_with_accept(&format!("/~{owner}/{repo}/tickets/{number}"));
    assert_eq!(response.status, 200, "ticket AP document should be served");
    serde_json::from_str(&response.body).expect("valid JSON")
}

/// Fetch a ticket replies collection.
fn fetch_ticket_replies(
    client: &mut support::Client,
    owner: &str,
    repo: &str,
    number: i64,
) -> serde_json::Value {
    let response = client.get_with_accept(&format!("/~{owner}/{repo}/tickets/{number}/replies"));
    assert_eq!(response.status, 200, "ticket replies should be served");
    serde_json::from_str(&response.body).expect("valid JSON")
}

/// Test that ticket documents are served with correct ForgeFed structure.
#[test]
fn ticket_document_has_correct_fields() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "This is a test ticket",
    );

    let ticket = fetch_ticket_ap(&mut client, "alice", "testrepo", 1);

    // Check @context includes ForgeFed
    let context = ticket["@context"].as_array().expect("@context is array");
    assert!(
        context
            .iter()
            .any(|c| c.as_str() == Some("https://forgefed.org/ns")),
        "@context should include ForgeFed namespace"
    );

    // Check type is Ticket
    assert_eq!(ticket["type"], "Ticket", "type should be Ticket");

    // Check id is the ticket URL
    assert!(
        ticket["id"]
            .as_str()
            .unwrap()
            .ends_with("/~alice/testrepo/tickets/1"),
        "id should be the ticket URL"
    );

    // Check required fields exist
    assert!(
        ticket["attributedTo"].is_string(),
        "attributedTo should be present"
    );
    assert!(ticket["summary"].is_string(), "summary should be present");
    assert!(ticket["content"].is_string(), "content should be present");
    assert!(
        ticket["mediaType"].is_string(),
        "mediaType should be present"
    );
    assert!(ticket["source"].is_object(), "source should be present");
    assert!(
        ticket["context"].is_string(),
        "context (tracker) should be present"
    );
    assert!(ticket["replies"].is_string(), "replies should be present");

    // Check source structure
    let source = &ticket["source"];
    assert_eq!(
        source["mediaType"], "text/markdown",
        "source mediaType should be markdown"
    );
    assert!(
        source["content"].is_string(),
        "source content should be present"
    );

    // Check isResolved is false for open tickets
    assert_eq!(
        ticket["isResolved"], false,
        "isResolved should be false for open tickets"
    );

    Ok(())
}

/// Test that closed tickets have isResolved=true.
#[test]
fn closed_ticket_is_resolved() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );

    // Close the ticket
    let response = client.post_form(
        "/~alice/testrepo/tickets/1",
        &[
            ("summary", "Test ticket"),
            ("description", "Description"),
            ("state", "closed"),
        ],
    );
    assert_eq!(response.status, 303);

    let ticket = fetch_ticket_ap(&mut client, "alice", "testrepo", 1);
    assert_eq!(
        ticket["isResolved"], true,
        "isResolved should be true for closed tickets"
    );
    // resolvedBy is set to the repo owner when closed (we don't track who
    // specifically closed it)
    assert!(
        ticket["resolvedBy"].is_string(),
        "resolvedBy should be present for closed tickets"
    );

    Ok(())
}

/// Test that ticket replies collection is served.
#[test]
fn ticket_replies_collection() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );

    // Add a comment
    let response = client.post_form(
        "/~alice/testrepo/tickets/1/comments",
        &[("content", "This is a comment")],
    );
    assert_eq!(response.status, 303);

    let replies = fetch_ticket_replies(&mut client, "alice", "testrepo", 1);

    // Check it's an OrderedCollection
    assert_eq!(
        replies["type"], "OrderedCollection",
        "replies should be OrderedCollection"
    );
    assert_eq!(replies["totalItems"], 1, "should have 1 reply");

    // Check orderedItems array contains URLs (valid ActivityPub pattern)
    let items = replies["orderedItems"]
        .as_array()
        .expect("orderedItems is array");
    assert_eq!(items.len(), 1, "should have 1 item");

    // The item is a URL string to the comment Note
    let comment_url = items[0].as_str().expect("item should be a URL string");
    assert!(
        comment_url.contains("/tickets/1/comments/"),
        "comment URL should point to the comment endpoint"
    );

    Ok(())
}

/// Test that patch documents are served with correct structure.
#[test]
fn patch_document_has_correct_fields() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Create a patch proposal via API (simulating git push)
    // For now, we'll just test that a non-existent patch returns 404
    let response = client.get_with_accept("/~alice/testrepo/patches/1");
    assert_eq!(response.status, 404, "non-existent patch should return 404");

    // TODO: Once we have a way to create patches via the API, test the full
    // document structure For now, this test just verifies the route exists
    // and handles 404 correctly

    Ok(())
}

/// Test that browsers get HTML, not JSON-LD.
#[test]
fn ticket_html_vs_json_ld_content_negotiation() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );

    // Fetch with browser Accept header
    let html_response = client.get("/~alice/testrepo/tickets/1");
    assert_eq!(html_response.status, 200);
    assert!(
        html_response
            .header("content-type")
            .is_some_and(|v| v.contains("text/html")),
        "browser should get HTML"
    );

    // Fetch with ActivityPub Accept header
    let ap_response = client.get_with_accept("/~alice/testrepo/tickets/1");
    assert_eq!(ap_response.status, 200);
    assert!(
        ap_response
            .header("content-type")
            .is_some_and(|v| v.contains("application/activity+json")),
        "AP client should get JSON-LD"
    );

    Ok(())
}

// ============================================================================
// Phase 3: Outbound federation tests
// ============================================================================

/// Test that creating a ticket emits an Accept activity to repository
/// followers. This verifies the Offer+Accept flow for local ticket creation.
#[test]
fn ticket_creation_emits_accept_to_followers() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will follow the repository
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Remote actor follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    remote_actor.follow(repo_url.clone());

    // Wait for the follow to be accepted
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    'follow_wait: while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(50));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
                    break 'follow_wait;
                }
            }
        }
    }

    // Drain deliveries to clear the Follow Accept before creating the ticket
    remote_actor.drain_deliveries();

    // Create a ticket
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "This is a test ticket",
    );

    // Wait for delivery
    let delivery = remote_actor
        .wait_for_delivery(5)
        .unwrap_or_else(|| panic!("should receive Accept activity\nServer log:"));

    // Parse the delivered activity
    let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");

    // Verify it's an Accept activity
    assert_eq!(
        activity["type"], "Accept",
        "delivered activity should be Accept"
    );
    assert_eq!(
        activity["actor"], repo_url,
        "Accept actor should be repository"
    );

    // Verify it has a result field pointing to the ticket
    assert!(
        activity["result"].is_string(),
        "Accept should have result field"
    );
    assert!(
        activity["result"].as_str().unwrap().contains("/tickets/1"),
        "result should point to the ticket"
    );

    // Verify the object is the Offer (should be a URL string)
    assert!(
        activity["object"].is_string(),
        "Accept object should be Offer URL"
    );

    Ok(())
}

/// Test that closing a ticket emits a Resolve activity to followers.
#[test]
fn ticket_close_emits_resolve_to_followers() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will follow the repository
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Remote actor follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    remote_actor.follow(repo_url.clone());

    // Wait for the follow to be accepted
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    'follow_wait: while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(50));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
                    break 'follow_wait;
                }
            }
        }
    }

    // Drain deliveries to clear the Follow Accept before creating the ticket
    remote_actor.drain_deliveries();

    // Create a ticket
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );

    // Clear deliveries from ticket creation
    remote_actor.drain_deliveries();

    // Close the ticket
    let response = client.post_form(
        "/~alice/testrepo/tickets/1",
        &[
            ("summary", "Test ticket"),
            ("description", "Description"),
            ("state", "closed"),
        ],
    );
    assert_eq!(response.status, 303);

    // Wait for Resolve delivery (loop until we find it or timeout)
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    let mut resolve_delivery = None;
    while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.drain_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Resolve") {
                    resolve_delivery = Some(delivery);
                    break;
                }
            }
        }
        if resolve_delivery.is_some() {
            break;
        }
    }

    let delivery = resolve_delivery.expect("should receive Resolve activity");

    // Parse the delivered activity
    let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");

    // Verify it's a Resolve activity
    assert_eq!(
        activity["type"], "Resolve",
        "delivered activity should be Resolve"
    );
    assert_eq!(
        activity["actor"], repo_url,
        "Resolve actor should be repository"
    );

    // Verify the object is the ticket URL
    assert!(
        activity["object"].is_string(),
        "Resolve object should be ticket URL"
    );
    assert!(
        activity["object"].as_str().unwrap().contains("/tickets/1"),
        "object should be the ticket URL"
    );

    Ok(())
}

// ============================================================================
// Phase 2 + 3: Comment federation tests
// ============================================================================

/// Helper to create a comment via the web form.
fn create_comment(
    client: &mut support::Client,
    owner: &str,
    repo: &str,
    ticket_number: i64,
    content: &str,
) {
    let response = client.post_form(
        &format!("/~{owner}/{repo}/tickets/{ticket_number}/comments"),
        &[("content", content)],
    );
    assert_eq!(response.status, 303, "creating comment should redirect");
}

/// Helper to fetch the first comment URL from the replies collection.
fn fetch_first_comment_url(
    client: &mut support::Client,
    owner: &str,
    repo: &str,
    ticket_number: i64,
) -> String {
    let replies = fetch_ticket_replies(client, owner, repo, ticket_number);
    let items = replies["orderedItems"]
        .as_array()
        .expect("orderedItems is array");
    assert!(!items.is_empty(), "should have at least one comment");
    items[0]
        .as_str()
        .expect("comment URL should be a string")
        .to_string()
}

/// Helper to fetch a comment Note document with ActivityPub content
/// negotiation.
fn fetch_comment_ap(
    client: &mut support::Client,
    port: u16,
    comment_url: &str,
) -> serde_json::Value {
    // Convert full URL to path
    let path = comment_url
        .strip_prefix(&format!("http://127.0.0.1:{}", port))
        .expect("comment URL should be on same host");
    let response = client.get_with_accept(path);
    assert_eq!(
        response.status, 200,
        "comment AP document should be served: {}",
        response.body
    );
    serde_json::from_str(&response.body).expect("valid JSON")
}

/// Test that comment Note documents are served with correct ForgeFed structure.
#[test]
fn comment_note_document_has_correct_fields() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );
    create_comment(
        &mut client,
        "alice",
        "testrepo",
        1,
        "This is a test comment",
    );

    let comment_url = fetch_first_comment_url(&mut client, "alice", "testrepo", 1);
    let comment = fetch_comment_ap(&mut client, port, &comment_url);

    // Check @context includes ForgeFed
    let context = comment["@context"].as_array().expect("@context is array");
    assert!(
        context
            .iter()
            .any(|c| c.as_str() == Some("https://forgefed.org/ns")),
        "@context should include ForgeFed namespace"
    );

    // Check type is Note
    assert_eq!(comment["type"], "Note", "type should be Note");

    // Check id is the comment URL
    assert_eq!(
        comment["id"].as_str().unwrap(),
        comment_url,
        "id should be the comment URL"
    );

    // Check required fields exist
    assert!(
        comment["attributedTo"].is_string(),
        "attributedTo should be present"
    );
    assert!(comment["content"].is_string(), "content should be present");
    assert!(
        comment["mediaType"].is_string(),
        "mediaType should be present"
    );
    assert!(
        comment["inReplyTo"].is_string(),
        "inReplyTo should be present"
    );
    assert!(comment["source"].is_object(), "source should be present");

    // Check inReplyTo points to the ticket
    let in_reply_to = comment["inReplyTo"].as_str().unwrap();
    assert!(
        in_reply_to.contains("/tickets/1"),
        "inReplyTo should point to the ticket"
    );

    // Check source structure
    let source = &comment["source"];
    assert_eq!(
        source["mediaType"], "text/markdown",
        "source mediaType should be markdown"
    );
    assert!(
        source["content"].is_string(),
        "source content should be present"
    );
    assert!(
        source["content"]
            .as_str()
            .unwrap()
            .contains("This is a test comment"),
        "source content should contain the original markdown"
    );

    // Check to and cc fields
    assert!(comment["to"].is_array(), "to should be an array");
    assert!(comment["cc"].is_array(), "cc should be an array");

    Ok(())
}

/// Test that creating a comment emits a Create(Note) activity to followers.
#[test]
fn comment_creation_emits_create_to_followers() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will follow the repository
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Remote actor follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    remote_actor.follow(repo_url.clone());

    // Wait for the follow to be accepted
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    'follow_wait: while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(50));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
                    break 'follow_wait;
                }
            }
        }
    }

    // Drain deliveries to clear the Follow Accept
    remote_actor.drain_deliveries();

    // Create a ticket
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
    );

    // Clear deliveries from ticket creation
    remote_actor.drain_deliveries();

    // Create a comment
    create_comment(
        &mut client,
        "alice",
        "testrepo",
        1,
        "This is a test comment",
    );

    // Wait for Create delivery (loop until we find it or timeout)
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    let mut create_delivery = None;
    while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.drain_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Create") {
                    create_delivery = Some(delivery);
                    break;
                }
            }
        }
        if create_delivery.is_some() {
            break;
        }
    }

    let delivery = create_delivery.expect("should receive Create activity");

    // Parse the delivered activity
    let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");

    // Verify it's a Create activity
    assert_eq!(
        activity["type"], "Create",
        "delivered activity should be Create"
    );

    // Verify the actor is the user (not the repository)
    assert!(
        activity["actor"].as_str().unwrap().contains("/~alice"),
        "Create actor should be the user"
    );

    // Verify the object is a Note
    assert!(
        activity["object"].is_object(),
        "Create object should be a Note object"
    );
    let note = &activity["object"];
    assert_eq!(note["type"], "Note", "object type should be Note");
    assert!(note["content"].is_string(), "Note should have content");
    assert!(note["inReplyTo"].is_string(), "Note should have inReplyTo");

    // Verify to and cc fields
    assert!(activity["to"].is_array(), "to should be an array");
    assert!(activity["cc"].is_array(), "cc should be an array");

    Ok(())
}

// Phase 4: Inbound federation tests

/// Test that a remote Offer activity creates a local ticket and sends Accept.
#[test]
fn remote_offer_creates_ticket_and_accepts() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Create a remote Offer activity with an embedded Ticket
    let ticket_id = format!("http://127.0.0.1:{remote_port}/tickets/1");
    let offer_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Offer",
        "id": format!("http://127.0.0.1:{remote_port}/offers/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "cc": ["https://www.w3.org/ns/activitystreams#Public"],
        "object": {
            "type": "Ticket",
            "id": ticket_id,
            "attributedTo": remote_actor.actor_url,
            "summary": "Remote ticket from offer",
            "content": "This is a ticket created via Offer",
            "attachment": []
        },
        "target": repo_url
    });

    // Send the Offer to the repository inbox
    remote_actor.send_activity(&inbox_url, offer_activity);

    // Wait for Accept delivery
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    let mut accept_delivery = None;
    while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.drain_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
                    accept_delivery = Some(delivery);
                    break;
                }
            }
        }
        if accept_delivery.is_some() {
            break;
        }
    }

    let delivery = accept_delivery.expect("should receive Accept activity");
    let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");

    // Verify the Accept is for the Offer
    assert_eq!(activity["type"], "Accept", "should be Accept");
    assert_eq!(
        activity["actor"].as_str().unwrap(),
        repo_url,
        "Accept actor should be repository"
    );

    // Verify the ticket was created by fetching it directly
    let ticket_path = format!("/~alice/testrepo/tickets/1");
    let response = client.get_with_accept(&ticket_path);
    let ticket_response: serde_json::Value = serde_json::from_str(&response.body)
        .map_err(|e| miette::miette!("Failed to parse JSON: {}", e))?;
    assert_eq!(
        ticket_response["type"], "Ticket",
        "should have created a ticket"
    );
    assert_eq!(
        ticket_response["summary"].as_str().unwrap(),
        "Remote ticket from offer",
        "ticket should have correct summary"
    );

    Ok(())
}

/// Test that a remote Resolve activity closes a local ticket.
#[test]
fn remote_resolve_closes_ticket() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Create a ticket via remote Offer so the remote actor is authorized
    let ticket_url = create_ticket_via_offer(
        &mut client,
        port,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
        &remote_actor,
    );

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Create a remote Resolve activity for ticket #1
    let resolve_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Resolve",
        "id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": ticket_url
    });

    // Send the Resolve to the repository inbox
    remote_actor.send_activity(&inbox_url, resolve_activity);

    // Wait for processing - give it more time for the delivery and processing
    std::thread::sleep(std::time::Duration::from_millis(2000));

    // Verify the ticket is closed
    let ticket_json = client.get_ap(&ticket_url)?;
    let is_resolved = ticket_json["isResolved"]
        .as_bool()
        .ok_or_else(|| miette::miette!("missing isResolved"))?;
    assert!(is_resolved, "ticket should be resolved");

    Ok(())
}

/// Test that a remote Undo(Resolve) activity reopens a local ticket.
#[test]
fn remote_undo_resolve_reopens_ticket() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Create a ticket via remote Offer so the remote actor is authorized
    let ticket_url = create_ticket_via_offer(
        &mut client,
        port,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
        &remote_actor,
    );

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Close the ticket first via Resolve
    let resolve_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Resolve",
        "id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": ticket_url
    });
    remote_actor.send_activity(&inbox_url, resolve_activity);
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Now send Undo(Resolve) to reopen
    let undo_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Undo",
        "id": format!("http://127.0.0.1:{remote_port}/undos/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": {
            "type": "Resolve",
            "id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
            "actor": remote_actor.actor_url,
            "object": ticket_url
        }
    });

    // Send the Undo to the repository inbox
    remote_actor.send_activity(&inbox_url, undo_activity);

    // Wait a bit for processing
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Verify the ticket is reopened
    let ticket_json = client.get_ap(&ticket_url)?;
    let is_resolved = ticket_json["isResolved"]
        .as_bool()
        .ok_or_else(|| miette::miette!("missing isResolved"))?;
    assert!(!is_resolved, "ticket should be reopened");

    Ok(())
}

/// Test that a remote Edit activity updates a local ticket.
#[test]
fn remote_edit_updates_ticket() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Create a ticket via remote Offer so the remote actor is authorized
    let ticket_url = create_ticket_via_offer(
        &mut client,
        port,
        "alice",
        "testrepo",
        "Original summary",
        "Original description",
        &remote_actor,
    );

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Create a remote Edit activity for ticket #1
    let edit_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Edit",
        "id": format!("http://127.0.0.1:{remote_port}/edits/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": {
            "type": "Ticket",
            "id": ticket_url,
            "summary": "Updated summary",
            "content": "Updated description"
        }
    });

    // Send the Edit to the repository inbox
    remote_actor.send_activity(&inbox_url, edit_activity);

    // Wait for processing - give it more time for the delivery and processing
    std::thread::sleep(std::time::Duration::from_millis(2000));

    // Verify the ticket was updated
    let ticket_json = client.get_ap(&ticket_url)?;
    let summary = ticket_json["summary"]
        .as_str()
        .ok_or_else(|| miette::miette!("missing summary"))?;
    assert_eq!(summary, "Updated summary", "summary should be updated");

    Ok(())
}

/// Test that a remote Create(Note) activity creates a comment on a ticket.
#[test]
fn remote_create_creates_comment() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Create a ticket via remote Offer
    let ticket_url = create_ticket_via_offer(
        &mut client,
        port,
        "alice",
        "testrepo",
        "Test ticket",
        "Description",
        &remote_actor,
    );

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Create a remote Create(Note) activity for a comment on ticket #1
    let note_id = format!("http://127.0.0.1:{remote_port}/notes/1");
    let create_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Create",
        "id": format!("http://127.0.0.1:{remote_port}/creates/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": {
            "type": "Note",
            "id": note_id,
            "attributedTo": remote_actor.actor_url,
            "inReplyTo": ticket_url,
            "content": "<p>This is a remote comment</p>",
            "source": {
                "mediaType": "text/markdown",
                "content": "This is a remote comment"
            }
        }
    });

    // Send the Create to the repository inbox
    remote_actor.send_activity(&inbox_url, create_activity);

    // Wait for processing - give it more time for the delivery and processing
    std::thread::sleep(std::time::Duration::from_millis(2000));

    // Verify the comment was created
    let replies_url = format!("{}/replies", ticket_url);
    let replies_json = client.get_ap(&replies_url)?;
    let total_items = replies_json["totalItems"]
        .as_u64()
        .ok_or_else(|| miette::miette!("missing totalItems"))?;
    assert_eq!(total_items, 1, "should have 1 comment");

    Ok(())
}

/// Test that a remote Offer with inline Patch is rejected politely.
#[test]
fn remote_offer_with_patch_is_rejected() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will send activities
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");

    // Create a remote Offer activity with an embedded Patch (MergeRequest)
    let patch_id = format!("http://127.0.0.1:{remote_port}/patches/1");
    let offer_activity = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Offer",
        "id": format!("http://127.0.0.1:{remote_port}/offers/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "cc": ["https://www.w3.org/ns/activitystreams#Public"],
        "object": {
            "type": "Patch",
            "id": patch_id,
            "attributedTo": remote_actor.actor_url,
            "summary": "Remote patch from offer",
            "content": "This is a patch",
            "attachment": [{
                "type": "PatchCollection",
                "origin": "http://remote.example/repo",
                "target": repo_url,
                "orderedItems": []
            }]
        },
        "target": repo_url
    });

    // Send the Offer to the repository inbox
    remote_actor.send_activity(&inbox_url, offer_activity);

    // Wait for Reject delivery
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    let mut reject_delivery = None;
    while std::time::Instant::now() < deadline {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.drain_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Reject") {
                    reject_delivery = Some(delivery);
                    break;
                }
            }
        }
        if reject_delivery.is_some() {
            break;
        }
    }

    let delivery = reject_delivery.expect("should receive Reject activity");
    let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");

    // Verify the Reject is for the Offer
    assert_eq!(activity["type"], "Reject", "should be Reject");
    assert_eq!(
        activity["actor"].as_str().unwrap(),
        repo_url,
        "Reject actor should be repository"
    );

    Ok(())
}

/// Test that a user can open a ticket on a remote tracker.
#[test]
fn remote_ticket_submission_creates_pending_offer() -> miette::Result<()> {
    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(&mut client, "alice");

    // Set up a remote repository that tracks its own tickets
    let remote_port = free_port();
    let remote_repo = RemoteActor::serve(remote_port);

    // Serve a Repository actor document with ticketsTrackedBy pointing to
    // itself
    let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");

    let repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", repo_url),
        "ticketsTrackedBy": tracker_url,
        "sendPatchesTo": tracker_url
    });

    remote_repo.serve_note_at_url("/repo", repo_doc);

    // Serve the tracker actor document
    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url),
        "tracksTicketsFor": [repo_url]
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Try to open a ticket on the remote tracker via the form
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &repo_url),
            ("kind", "ticket"),
            ("summary", "Remote test ticket"),
            ("description", "This is a test ticket on a remote tracker"),
        ],
    );

    // Should redirect after successful submission
    assert_eq!(
        response.status, 303,
        "remote ticket submission should redirect: {}",
        response.body
    );

    // Wait for the Offer to be delivered to the remote tracker
    // The delivery worker checks every 2 seconds, so poll with a timeout
    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(5);
    let mut deliveries = Vec::new();
    while start.elapsed() < timeout {
        deliveries = remote_repo.drain_deliveries();
        if !deliveries.is_empty() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    assert!(!deliveries.is_empty(), "should have delivered Offer");

    let offer_delivery = &deliveries[0];
    let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");

    assert_eq!(offer["type"], "Offer", "should be an Offer activity");
    assert_eq!(
        offer["actor"].as_str().unwrap(),
        format!("http://127.0.0.1:{port}/~alice"),
        "Offer actor should be alice"
    );
    assert_eq!(
        offer["target"].as_str().unwrap(),
        repo_url,
        "Offer target should be remote repo"
    );
    assert_eq!(
        offer["object"]["type"], "Ticket",
        "Offer object should be a Ticket"
    );
    assert_eq!(
        offer["object"]["summary"].as_str().unwrap(),
        "Remote test ticket",
        "Ticket summary should match"
    );

    Ok(())
}

/// Test that when a remote tracker Accepts our Offer, the pending ticket is
/// marked accepted.
#[test]
fn remote_ticket_acceptance_updates_status() -> miette::Result<()> {
    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(&mut client, "alice");

    // Set up a remote repository that tracks its own tickets
    let remote_port = free_port();
    let remote_repo = RemoteActor::serve(remote_port);

    let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");

    let repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", repo_url),
        "ticketsTrackedBy": tracker_url,
        "sendPatchesTo": tracker_url
    });

    remote_repo.serve_note_at_url("/repo", repo_doc);

    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url),
        "tracksTicketsFor": [repo_url]
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Submit a remote ticket
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &repo_url),
            ("kind", "ticket"),
            ("summary", "Remote test ticket"),
            ("description", "This is a test ticket on a remote tracker"),
        ],
    );

    assert_eq!(
        response.status, 303,
        "remote ticket submission should redirect: {}",
        response.body
    );

    // Wait for the Offer to be delivered
    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(5);
    let mut deliveries = Vec::new();
    while start.elapsed() < timeout {
        deliveries = remote_repo.drain_deliveries();
        if !deliveries.is_empty() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    assert!(!deliveries.is_empty(), "should have delivered Offer");

    let offer_delivery = &deliveries[0];
    let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");
    let offer_id = offer["id"].as_str().expect("offer has id").to_string();

    // Now the remote tracker sends back an Accept
    let assigned_ticket_url = format!("http://127.0.0.1:{remote_port}/tickets/42");
    let accept = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/accept-1", remote_repo.actor_url),
        "type": "Accept",
        "actor": remote_repo.actor_url,
        "object": offer_id,
        "result": assigned_ticket_url
    });

    // Send the Accept to alice's inbox
    remote_repo.send_activity(&format!("http://127.0.0.1:{port}/~alice/inbox"), accept);

    // Wait for the Accept to be processed
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Check that the remote ticket is now marked as accepted
    let response = client.get("/~alice/remote-tickets");
    assert_eq!(response.status, 200, "should show remote tickets page");
    assert!(
        response.body.contains("accepted"),
        "remote ticket should be marked as accepted"
    );
    assert!(
        response.body.contains(&assigned_ticket_url),
        "should show the assigned ticket URL"
    );

    Ok(())
}

/// Test that when a remote tracker Rejects our Offer, the pending ticket is
/// marked rejected.
#[test]
fn remote_ticket_rejection_updates_status() -> miette::Result<()> {
    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(&mut client, "alice");

    // Set up a remote repository
    let remote_port = free_port();
    let remote_repo = RemoteActor::serve(remote_port);

    let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");

    let repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", repo_url),
        "ticketsTrackedBy": tracker_url
    });

    remote_repo.serve_note_at_url("/repo", repo_doc);

    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url),
        "tracksTicketsFor": [repo_url]
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Submit a remote ticket
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &repo_url),
            ("kind", "ticket"),
            ("summary", "Remote test ticket"),
            ("description", "This ticket will be rejected"),
        ],
    );

    assert_eq!(response.status, 303);

    // Wait for delivery
    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(5);
    let mut deliveries = Vec::new();
    while start.elapsed() < timeout {
        deliveries = remote_repo.drain_deliveries();
        if !deliveries.is_empty() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    assert!(!deliveries.is_empty());

    let offer_delivery = &deliveries[0];
    let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");
    let offer_id = offer["id"].as_str().expect("offer has id").to_string();

    // Remote tracker sends a Reject
    let reject = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/reject-1", remote_repo.actor_url),
        "type": "Reject",
        "actor": remote_repo.actor_url,
        "object": offer_id,
        "summary": "We don't accept external tickets"
    });

    remote_repo.send_activity(&format!("http://127.0.0.1:{port}/~alice/inbox"), reject);

    // Wait for processing
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Check that the remote ticket is now marked as rejected
    let response = client.get("/~alice/remote-tickets");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("rejected"),
        "remote ticket should be marked as rejected"
    );

    Ok(())
}

/// Test that our own repository actor document includes tracksTicketsFor and
/// tracksPatchesFor = [self].
#[test]
fn repo_actor_includes_tracks_fields() -> 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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Fetch the repo actor document with ActivityPub content negotiation
    let response = client.get_with_accept("/~alice/testrepo");
    assert_eq!(
        response.status, 200,
        "repo actor should be served: {}",
        response.body
    );

    let repo_actor: serde_json::Value = serde_json::from_str(&response.body).expect("valid JSON");

    // Verify it's a Repository
    assert_eq!(
        repo_actor["type"], "Repository",
        "should be a Repository actor"
    );

    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    assert_eq!(
        repo_actor["id"].as_str().unwrap(),
        repo_url,
        "repo actor id should be the repo URL"
    );

    // Verify tracksTicketsFor = [self]
    let tracks_tickets = repo_actor["tracksTicketsFor"]
        .as_array()
        .expect("tracksTicketsFor should be an array");
    assert_eq!(
        tracks_tickets.len(),
        1,
        "tracksTicketsFor should have 1 element"
    );
    assert_eq!(
        tracks_tickets[0].as_str().unwrap(),
        repo_url,
        "tracksTicketsFor should contain the repo URL itself"
    );

    // Verify tracksPatchesFor = [self]
    let tracks_patches = repo_actor["tracksPatchesFor"]
        .as_array()
        .expect("tracksPatchesFor should be an array");
    assert_eq!(
        tracks_patches.len(),
        1,
        "tracksPatchesFor should have 1 element"
    );
    assert_eq!(
        tracks_patches[0].as_str().unwrap(),
        repo_url,
        "tracksPatchesFor should contain the repo URL itself"
    );

    Ok(())
}

/// Test that remote ticket submission is rejected when the tracker doesn't
/// advertise tracksTicketsFor.
#[test]
fn remote_ticket_rejected_when_tracker_lacks_back_reference() -> miette::Result<()> {
    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(&mut client, "alice");

    // Set up a remote repository with a separate tracker that doesn't advertise
    // tracksTicketsFor
    let remote_port = free_port();
    let remote_repo = RemoteActor::serve(remote_port);

    let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");

    // Repo points to a separate tracker (not self-tracking)
    let repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", repo_url),
        "ticketsTrackedBy": tracker_url,
        "sendPatchesTo": tracker_url
    });

    remote_repo.serve_note_at_url("/repo", repo_doc);

    // Tracker does NOT include tracksTicketsFor (missing back-reference)
    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url)
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Try to open a ticket on the remote tracker
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &repo_url),
            ("kind", "ticket"),
            ("summary", "Remote test ticket"),
            (
                "description",
                "This should be rejected due to missing back-reference",
            ),
        ],
    );

    // Should show an error (not redirect with 303)
    assert_ne!(
        response.status, 303,
        "submission should fail when tracker lacks tracksTicketsFor"
    );
    assert!(
        response.body.contains("tracksTicketsFor") || response.body.contains("bidirectional"),
        "error should mention the missing back-reference: {}",
        response.body
    );

    // Verify no Offer was delivered
    let deliveries = remote_repo.recorded_deliveries();
    assert!(
        deliveries.is_empty(),
        "should not deliver Offer when bidirectional reference is missing"
    );

    Ok(())
}

/// Test that patch submission requires origin repository and branch fields.
#[test]
fn remote_patch_requires_origin_fields() -> miette::Result<()> {
    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(&mut client, "alice");

    // Set up a remote repository that accepts patches
    let remote_port = free_port();
    let remote_repo = RemoteActor::serve(remote_port);

    let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");

    let repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", repo_url),
        "sendPatchesTo": tracker_url
    });

    remote_repo.serve_note_at_url("/repo", repo_doc);

    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url),
        "tracksPatchesFor": [repo_url]
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Try to submit a patch without origin fields - should fail
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &repo_url),
            ("kind", "mr"),
            ("summary", "Remote test patch"),
            ("description", "This patch is missing origin fields"),
        ],
    );

    // Should show an error (not redirect with 303)
    assert_ne!(
        response.status, 303,
        "patch submission should fail without origin fields"
    );
    assert!(
        response.body.contains("origin") || response.body.contains("branch"),
        "error should mention missing origin fields: {}",
        response.body
    );

    Ok(())
}

/// Test that outbound patch submission generates a Patch object with patch
/// collection.
#[test]
fn remote_patch_submission_generates_patch_object() -> 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(&mut client, "alice");

    // Create a local repository for alice
    create_repository(&mut client, "alice", "origin-repo", "public");

    // Populate the repository with commits on a feature branch
    let scratch = Scratch::new();
    let bare = server.repo_root().join("alice").join("origin-repo.git");
    let work = scratch.path("work");
    fs::create_dir_all(&work).expect("the working tree should be creatable");

    // Initialize working tree
    assert_ok(&git(&work, &["init", "-q", "-b", "main"]), "initialising");
    fs::write(
        work.join("README.md"),
        "# Origin Repo\n\nInitial content.\n",
    )
    .expect("writing the readme");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Initial commit"]),
        "committing",
    );

    // Create a feature branch with changes
    assert_ok(
        &git(&work, &["checkout", "-q", "-b", "feature"]),
        "creating feature branch",
    );
    fs::write(work.join("feature.txt"), "New feature content.\n").expect("writing feature file");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Add feature"]),
        "committing feature",
    );

    // Push to the bare repository
    assert_ok(
        &git(&work, &["push", "-q", bare.to_str().unwrap(), "main"]),
        "pushing main",
    );
    assert_ok(
        &git(&work, &["push", "-q", bare.to_str().unwrap(), "feature"]),
        "pushing feature branch",
    );

    // Set up a remote repository that accepts patches
    let remote_port = free_port();
    let remote_repo = support::ap::RemoteActor::serve(remote_port);

    let target_repo_url = format!("http://127.0.0.1:{remote_port}/target-repo");
    let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
    let origin_repo_url = format!("http://127.0.0.1:{port}/~alice/origin-repo");

    let target_repo_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": target_repo_url,
        "type": "Repository",
        "inbox": format!("{}/inbox", target_repo_url),
        "sendPatchesTo": tracker_url
    });

    remote_repo.serve_note_at_url("/target-repo", target_repo_doc);

    let tracker_doc = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "id": tracker_url,
        "type": "Tracker",
        "inbox": format!("{}/inbox", tracker_url),
        "tracksPatchesFor": [target_repo_url]
    });

    remote_repo.serve_note_at_url("/tracker", tracker_doc);

    // Submit a patch
    let response = client.post_form(
        "/~alice/remote-tickets",
        &[
            ("target", &target_repo_url),
            ("kind", "mr"),
            ("summary", "Add new feature"),
            ("description", "This patch adds a new feature"),
            ("origin_repo", &origin_repo_url),
            ("origin_branch", "feature"),
            ("target_branch", "main"),
        ],
    );

    // Should redirect with 303 on success
    assert_eq!(
        response.status, 303,
        "patch submission should succeed: {}",
        response.body
    );

    // Wait for delivery
    std::thread::sleep(std::time::Duration::from_millis(2000));

    // Verify the Offer was delivered and contains a Patch object
    let delivered = remote_repo.recorded_deliveries();
    assert!(!delivered.is_empty(), "should have delivered an activity");

    let offer: serde_json::Value = serde_json::from_str(&delivered[0].body).expect("valid JSON");
    assert_eq!(offer["type"], "Offer", "should be an Offer");
    assert_eq!(offer["object"]["type"], "Patch", "object should be a Patch");

    // Verify the Patch has an attachment with a Branch
    assert_eq!(
        offer["object"]["attachment"]["type"], "Branch",
        "attachment should be a Branch"
    );
    assert_eq!(
        offer["object"]["attachment"]["name"], "feature",
        "branch name should match"
    );

    // Verify the Patch has a Collection object
    assert_eq!(
        offer["object"]["object"]["type"], "Collection",
        "object.object should be a Collection"
    );

    // Verify the collection has items (patches)
    let items = offer["object"]["object"]["items"]
        .as_array()
        .expect("items should be an array");
    assert!(
        !items.is_empty(),
        "collection should have at least one patch"
    );

    // Each item should be an Article with content
    for item in items {
        assert_eq!(item["type"], "Article", "each item should be an Article");
        assert!(
            item["content"].as_str().is_some(),
            "each Article should have content"
        );
    }

    Ok(())
}

/// Scratch directory for git working trees.
struct Scratch {
    dir: std::path::PathBuf,
}

impl Scratch {
    fn new() -> Self {
        let dir = std::env::temp_dir().join(format!("frogue-test-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&dir).expect("scratch dir should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> std::path::PathBuf {
        let p = self.dir.join(name);
        fs::create_dir_all(&p).expect("subdirectory should be creatable");
        p
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.dir);
    }
}

/// Runs `git` in `dir` with the test's own identity.
fn git(dir: &Path, args: &[&str]) -> Output {
    Command::new("git")
        .current_dir(dir)
        .args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
        .args(args)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("git should run")
}

/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
    assert!(
        output.status.success(),
        "{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// Test that remote ticket creators are displayed with remote badge and link in
/// WebUI.
#[test]
fn remote_ticket_author_displayed_with_badge() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up a remote actor that will create a ticket
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Create a ticket via remote Offer
    let _ticket_url = create_ticket_via_offer(
        &mut client,
        port,
        "alice",
        "testrepo",
        "Remote ticket",
        "This ticket was created by a remote actor",
        &remote_actor,
    );

    // Wait for processing
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Fetch the ticket list page (HTML)
    let response = client.get("/~alice/testrepo/tickets");
    assert_eq!(response.status, 200, "ticket list should load");

    // Verify the remote badge is present
    assert!(
        response.body.contains("remote-badge"),
        "ticket list should show remote badge for remote author: {}",
        response.body
    );

    // Verify the author name is present (should be the remote actor's
    // preferred_username or display name) The RemoteActor serves a default
    // actor with name "Remote User"
    assert!(
        response.body.contains("Remote User") || response.body.contains("remote"),
        "ticket list should show remote author name: {}",
        response.body
    );

    // Fetch the individual ticket page (HTML)
    let response = client.get("/~alice/testrepo/tickets/1");
    assert_eq!(response.status, 200, "ticket detail should load");

    // Verify the remote badge is present on the detail page
    assert!(
        response.body.contains("remote-badge"),
        "ticket detail should show remote badge for remote author: {}",
        response.body
    );

    Ok(())
}

/// Test that remote comment authors are displayed with remote badge and link in
/// WebUI.
#[test]
fn remote_comment_author_displayed_with_badge() -> miette::Result<()> {
    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(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Create a local ticket first
    create_ticket(
        &mut client,
        "alice",
        "testrepo",
        "Local ticket",
        "This is a local ticket",
    );

    // Set up a remote actor that will comment
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Get the repository actor's inbox URL
    let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
    let inbox_url = format!("{repo_url}/inbox");
    let ticket_url = format!("{repo_url}/tickets/1");

    // Create a remote comment via Create(Note) activity
    let note_id = format!("http://127.0.0.1:{remote_port}/notes/1");
    let create_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Create",
        "id": format!("http://127.0.0.1:{remote_port}/creates/1"),
        "actor": remote_actor.actor_url,
        "to": [repo_url],
        "object": {
            "type": "Note",
            "id": note_id,
            "attributedTo": remote_actor.actor_url,
            "inReplyTo": ticket_url,
            "content": "<p>This is a remote comment</p>",
            "source": {
                "mediaType": "text/markdown",
                "content": "This is a remote comment"
            }
        }
    });

    // Send the Create to the repository inbox
    remote_actor.send_activity(&inbox_url, create_activity);

    // Wait for processing
    std::thread::sleep(std::time::Duration::from_millis(2000));

    // Fetch the ticket detail page (HTML)
    let response = client.get("/~alice/testrepo/tickets/1");
    assert_eq!(response.status, 200, "ticket detail should load");

    // Verify the remote badge is present for the comment
    assert!(
        response.body.contains("remote-badge"),
        "ticket detail should show remote badge for remote commenter: {}",
        response.body
    );

    // Verify the comment content is present
    assert!(
        response.body.contains("remote comment"),
        "ticket detail should show the remote comment: {}",
        response.body
    );

    Ok(())
}
