//! ForgeFed Repository actor tests
//!
//! Tests for the Repository actor type implementation, including:
//! - Actor document with ForgeFed context and properties
//! - Follow/Accept flow for repository actors
//! - Push activity emission from git hooks

mod support;

use std::time::{Duration, Instant};

use support::ap::RemoteActor;
use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

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

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

/// Test 1: Repository actor document has correct ForgeFed structure
#[tokio::test]
async fn repository_actor_document_is_correct() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "proj", "public");

    // Request the repository actor document
    let response =
        alice_client.get_with("/~alice/proj", &[("Accept", "application/activity+json")]);

    assert_eq!(
        response.status, 200,
        "Repository actor should be accessible"
    );

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

    // Verify @context includes ForgeFed namespace
    let context = actor["@context"]
        .as_array()
        .expect("@context should be an array");
    assert!(
        context.iter().any(|c| c == "https://forgefed.org/ns"),
        "@context should include https://forgefed.org/ns"
    );

    // Verify type is Repository
    assert_eq!(actor["type"], "Repository", "type should be Repository");

    // Verify id
    assert_eq!(
        actor["id"],
        format!("http://127.0.0.1:{port}/~alice/proj"),
        "id should be the repository URL"
    );

    // Verify attributedTo points to owner
    assert_eq!(
        actor["attributedTo"],
        format!("http://127.0.0.1:{port}/~alice"),
        "attributedTo should point to owner"
    );

    // Verify inbox
    assert_eq!(
        actor["inbox"],
        format!("http://127.0.0.1:{port}/~alice/proj/inbox"),
        "inbox should be present"
    );

    // Verify outbox
    assert_eq!(
        actor["outbox"],
        format!("http://127.0.0.1:{port}/~alice/proj/outbox"),
        "outbox should be present"
    );

    // Verify followers
    assert_eq!(
        actor["followers"],
        format!("http://127.0.0.1:{port}/~alice/proj/followers"),
        "followers should be present"
    );

    // Verify publicKey
    assert!(
        actor["publicKey"]["id"].as_str().is_some(),
        "publicKey.id should be present"
    );
    assert_eq!(
        actor["publicKey"]["owner"],
        format!("http://127.0.0.1:{port}/~alice/proj"),
        "publicKey.owner should match actor id"
    );

    // Verify name and summary
    assert_eq!(actor["name"], "proj", "name should be the repository slug");
    assert_eq!(
        actor["summary"], "A test repository",
        "summary should be the description"
    );

    // Verify cloneUri
    assert_eq!(
        actor["cloneUri"],
        format!("http://127.0.0.1:{port}/alice/proj.git"),
        "cloneUri should be the HTTP clone URL"
    );

    // Verify pushUri is an array
    assert!(actor["pushUri"].is_array(), "pushUri should be an array");
    let push_uris = actor["pushUri"].as_array().unwrap();
    assert!(
        push_uris.len() >= 2,
        "pushUri should have at least HTTP and SSH URLs"
    );

    // Verify published timestamp
    assert!(
        actor["published"].as_str().is_some(),
        "published should be present"
    );

    // Verify ticketsTrackedBy (self-referential)
    assert_eq!(
        actor["ticketsTrackedBy"],
        format!("http://127.0.0.1:{port}/~alice/proj"),
        "ticketsTrackedBy should be self-referential"
    );

    // Verify sendPatchesTo (self-referential)
    assert_eq!(
        actor["sendPatchesTo"],
        format!("http://127.0.0.1:{port}/~alice/proj"),
        "sendPatchesTo should be self-referential"
    );

    Ok(())
}

/// Test 2: Private repository actor returns 404
#[tokio::test]
async fn private_repository_actor_not_found() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "secret", "private");

    // Request the repository actor document
    let response =
        alice_client.get_with("/~alice/secret", &[("Accept", "application/activity+json")]);

    assert_eq!(
        response.status, 404,
        "Private repository actor should not be accessible"
    );

    Ok(())
}

/// Test 3: Repository actor keypair is created lazily
#[tokio::test]
async fn lazy_keypair_creation_for_repository() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "lazy", "public");

    // First request should create the keypair
    let response1 =
        alice_client.get_with("/~alice/lazy", &[("Accept", "application/activity+json")]);
    assert_eq!(response1.status, 200);
    let actor1: serde_json::Value =
        serde_json::from_str(&response1.body).expect("response should be valid JSON");

    // Second request should return the same keypair
    let response2 =
        alice_client.get_with("/~alice/lazy", &[("Accept", "application/activity+json")]);
    assert_eq!(response2.status, 200);
    let actor2: serde_json::Value =
        serde_json::from_str(&response2.body).expect("response should be valid JSON");

    // The publicKeyPem should be identical
    assert_eq!(
        actor1["publicKey"]["publicKeyPem"], actor2["publicKey"]["publicKeyPem"],
        "Keypair should be stable across requests"
    );

    Ok(())
}

/// Test 4: Remote actor can follow a repository and receive Accept
#[tokio::test]
async fn repository_actor_follow_flow() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "followed", "public");

    // Start a remote actor recorder
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Bob follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/followed");
    let follow_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/follow/1", recorder.actor_url),
        "type": "Follow",
        "actor": recorder.actor_url,
        "object": repo_url
    });
    let body = serde_json::to_string(&follow_json).unwrap();

    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/followed/inbox",
        &body,
    );

    let response = alice_client.post_json_with_headers(
        "/~alice/followed/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    assert_eq!(
        response.status, 202,
        "Repository inbox should accept Follow"
    );

    // Poll for Accept(Follow) delivery
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut found_accept = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));

        let deliveries = recorder.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") {
                    if let Some(obj) = activity.get("object") {
                        if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                            found_accept = true;
                            break;
                        }
                    }
                }
            }
        }
        if found_accept {
            break;
        }
    }

    assert!(
        found_accept,
        "Accept(Follow) should be delivered to the follower"
    );

    Ok(())
}

/// Test 5: Repository followers collection is populated
#[tokio::test]
async fn repository_followers_collection_populated() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "tracked", "public");

    // Start a remote actor recorder
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Bob follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/tracked");
    let follow_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/follow/2", recorder.actor_url),
        "type": "Follow",
        "actor": recorder.actor_url,
        "object": repo_url
    });
    let body = serde_json::to_string(&follow_json).unwrap();

    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/tracked/inbox",
        &body,
    );

    alice_client.post_json_with_headers(
        "/~alice/tracked/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

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

    // Request the followers collection
    let response = alice_client.get_with(
        "/~alice/tracked/followers",
        &[("Accept", "application/activity+json")],
    );

    assert_eq!(
        response.status, 200,
        "Followers collection should be accessible"
    );

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

    assert_eq!(
        followers["type"], "OrderedCollection",
        "type should be OrderedCollection"
    );

    assert_eq!(followers["totalItems"], 1, "totalItems should be 1");

    let items = followers["orderedItems"]
        .as_array()
        .expect("orderedItems should be an array");
    assert_eq!(items.len(), 1, "There should be one follower");
    assert_eq!(
        items[0], recorder.actor_url,
        "The follower should be the remote actor"
    );

    Ok(())
}

/// Test 6: Commit document is served
#[tokio::test]
async fn commit_document_is_served() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "commits", "public");

    // For now, we'll just test that the endpoint exists and returns 404 for
    // unknown commits In the future, we'll push a real commit and verify
    // it's served
    let response = alice_client.get_with(
        "/~alice/commits/commits/abc123def456",
        &[("Accept", "application/activity+json")],
    );

    assert_eq!(response.status, 404, "Unknown commit should return 404");

    Ok(())
}

/// Test 7: Repository outbox lists Push activities (initially empty)
#[tokio::test]
async fn repository_outbox_lists_pushes() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "pushed", "public");

    // Request the outbox
    let response = alice_client.get_with(
        "/~alice/pushed/outbox",
        &[("Accept", "application/activity+json")],
    );

    assert_eq!(response.status, 200, "Outbox should be accessible");

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

    assert_eq!(
        outbox["type"], "OrderedCollection",
        "type should be OrderedCollection"
    );

    // Initially empty (no pushes yet)
    assert_eq!(outbox["totalItems"], 0, "totalItems should be 0 initially");

    Ok(())
}

/// Test 8: Push emits Push activity (end-to-end)
/// This test verifies that pushing a commit over SSH emits a Push activity
/// that appears in the outbox and is delivered to followers.
#[tokio::test]
async fn push_emits_push_activity() -> miette::Result<()> {
    use std::fs;
    use std::process::Command;

    // Check for required tools
    if !Command::new("git").arg("--version").output().is_ok()
        || !Command::new("ssh").arg("-V").output().is_ok()
        || !Command::new("ssh-keygen").arg("--version").output().is_ok()
    {
        eprintln!("skipping: this test needs `git`, `ssh` and `ssh-keygen` on PATH");
        return Ok(());
    }

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

    // Create scratch directory
    let scratch = std::env::temp_dir().join(format!("frogue-push-test-{}", std::process::id()));
    fs::create_dir_all(&scratch).expect("scratch dir should be creatable");

    // Generate SSH key
    let key_path = scratch.join("id_ed25519");
    let output = Command::new("ssh-keygen")
        .args([
            "-t",
            "ed25519",
            "-f",
            key_path.to_str().unwrap(),
            "-N",
            "",
            "-C",
            "test",
        ])
        .output()
        .expect("ssh-keygen should run");
    assert!(
        output.status.success(),
        "ssh-keygen failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
            .expect("key permissions should be settable");
    }

    // Register user and add SSH key
    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    let pub_key =
        fs::read_to_string(key_path.with_extension("pub")).expect("pub key should be readable");
    let response = alice_client.post_form("/~alice/ssh-keys", &[("key", pub_key.trim())]);
    assert_eq!(response.status, 303, "adding SSH key should redirect");

    create_repository(&mut alice_client, "alice", "push-test", "public");

    // Start a remote actor that will follow the repository
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Remote actor follows the repository
    let repo_url = format!("http://127.0.0.1:{port}/~alice/push-test");
    let follow_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/follow/1", recorder.actor_url),
        "type": "Follow",
        "actor": recorder.actor_url,
        "object": repo_url
    });
    let body = serde_json::to_string(&follow_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/push-test/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/push-test/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Wait for Accept
    let deadline = Instant::now() + Duration::from_secs(10);
    'outer: while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.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 'outer;
                }
            }
        }
    }

    // Clone the repository over SSH
    let ssh_cmd = format!(
        "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o PreferredAuthentications=publickey -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p {}",
        key_path.display(),
        ssh_port
    );
    let clone_dir = scratch.join("clone");
    let output = Command::new("git")
        .env("GIT_SSH_COMMAND", &ssh_cmd)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .args([
            "clone",
            &format!("ssh://git@127.0.0.1:{}/alice/push-test.git", ssh_port),
            clone_dir.to_str().unwrap(),
        ])
        .output()
        .expect("git clone should run");
    assert!(
        output.status.success(),
        "git clone failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    // Make a commit and push
    fs::write(clone_dir.join("README.md"), "# Test\n").expect("file should be writable");

    let git_cmd = |args: &[&str]| {
        Command::new("git")
            .current_dir(&clone_dir)
            .env("GIT_SSH_COMMAND", &ssh_cmd)
            .env("GIT_TERMINAL_PROMPT", "0")
            .env("GIT_CONFIG_GLOBAL", "/dev/null")
            .env("GIT_CONFIG_SYSTEM", "/dev/null")
            .args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
            .args(args)
            .output()
            .expect("git should run")
    };

    assert_ok(&git_cmd(&["add", "README.md"]), "git add");
    assert_ok(&git_cmd(&["commit", "-m", "Initial commit"]), "git commit");
    assert_ok(&git_cmd(&["push", "origin", "main"]), "git push");

    // Wait for Push activity to be emitted
    std::thread::sleep(Duration::from_millis(500));

    // Verify outbox has the Push activity
    let response = alice_client.get_with(
        "/~alice/push-test/outbox?page=1",
        &[("Accept", "application/activity+json")],
    );
    assert_eq!(response.status, 200, "outbox should be accessible");

    let outbox: serde_json::Value =
        serde_json::from_str(&response.body).expect("outbox should be JSON");
    assert!(
        outbox["totalItems"].as_u64().unwrap_or(0) >= 1,
        "outbox should have at least 1 Push activity"
    );

    let items = outbox["orderedItems"]
        .as_array()
        .expect("orderedItems should be an array");
    let push = &items[0];
    assert_eq!(push["type"], "Push", "first item should be a Push");
    assert_eq!(
        push["actor"], repo_url,
        "Push actor should be repository URL"
    );
    assert_eq!(
        push["attributedTo"],
        format!("http://127.0.0.1:{port}/~alice"),
        "attributedTo should be pusher"
    );
    assert_eq!(push["target"], repo_url, "target should be repository URL");

    // Verify the remote actor received the Push delivery
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut found_push = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.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("Push") {
                    found_push = true;
                    break;
                }
            }
        }
        if found_push {
            break;
        }
    }
    assert!(
        found_push,
        "remote actor should have received Push delivery"
    );

    // Cleanup
    let _ = fs::remove_dir_all(&scratch);

    Ok(())
}

fn assert_ok(output: &std::process::Output, what: &str) {
    assert!(
        output.status.success(),
        "{} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        what,
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// Test 9: HTML content negotiation regression test
/// Verifies that requesting a repository page with Accept: text/html returns
/// HTML, not JSON.
#[tokio::test]
async fn repo_page_still_html_for_browsers() -> 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 alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "html-test", "public");

    // Request with text/html Accept header
    let response = alice_client.get("/~alice/html-test");

    assert_eq!(
        response.status, 200,
        "Repository page should be accessible as HTML"
    );

    // Verify it's HTML (not JSON)
    let content_type = response.content_type.as_deref().unwrap_or("");
    assert!(
        content_type.contains("text/html"),
        "Content-Type should be text/html, got: {}",
        content_type
    );

    // Verify it contains HTML content
    assert!(
        response.body.contains("<!DOCTYPE html>") || response.body.contains("<html"),
        "Response should be HTML"
    );

    Ok(())
}

/// Test 10: Federation-disabled regression test
/// Verifies that when federation is disabled (no FROGUE_PUBLIC_URL), all
/// repository ActivityPub endpoints return 404.
#[tokio::test]
async fn federation_disabled_repo_endpoints_404() -> miette::Result<()> {
    let port = free_port();
    // Spawn server WITHOUT federation enabled
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");
    create_repository(&mut alice_client, "alice", "no-fed", "public");

    // All ActivityPub endpoints should return 404
    // Note: inbox is POST-only, so we test it separately below
    let endpoints = vec![
        "/~alice/no-fed",
        "/~alice/no-fed/outbox",
        "/~alice/no-fed/followers",
        "/~alice/no-fed/commits/abc123",
    ];

    for endpoint in endpoints {
        let response = alice_client.get_with(endpoint, &[("Accept", "application/activity+json")]);
        assert_eq!(
            response.status, 404,
            "{} should return 404 when federation is disabled",
            endpoint
        );
    }

    // Test POST to inbox separately
    let response = alice_client.post_json(
        "/~alice/no-fed/inbox",
        "{}",
        &[("Content-Type", "application/activity+json")],
    );
    assert_eq!(
        response.status, 404,
        "inbox should return 404 when federation is disabled"
    );

    Ok(())
}
