//! Integration tests for diff snapshot preservation on merge/close.
//!
//! When a patch proposal is merged or closed, we need to freeze the diff state
//! so that viewing the patch later still shows what was actually merged/closed,
//! even if the branches are deleted or move forward.

mod support;

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

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

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

static COUNTER: AtomicU64 = AtomicU64::new(0);

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);
    let ssh_port = server.ssh_port();
    server.wait_for_connection(ssh_port);
    (server, port)
}

struct Scratch {
    dir: PathBuf,
}

impl Scratch {
    fn new(label: &str) -> Self {
        let count = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
        fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }
}

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

fn keygen(scratch: &Scratch, name: &str) -> PathBuf {
    let key = scratch.path(name);
    let output = Command::new("ssh-keygen")
        .arg("-t")
        .arg("ed25519")
        .arg("-f")
        .arg(&key)
        .arg("-N")
        .arg("")
        .arg("-C")
        .arg(name)
        .output()
        .expect("ssh-keygen should run");
    assert!(output.status.success(), "ssh-keygen failed");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(&key, fs::Permissions::from_mode(0o600))
            .expect("the private key should be readable by its owner alone");
    }

    key
}

fn register_and_login(client: &mut Client, name: &str, email: &str) {
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", email), ("password", PASSWORD)],
    );
    assert_eq!(response.status, 303, "registering should log in");
}

fn add_key(client: &mut Client, owner: &str, public_key: &Path) {
    let key = fs::read_to_string(public_key).expect("the public key should be readable");
    let response = client.post_form(&format!("/~{owner}/ssh-keys"), &[("key", key.trim())]);
    assert_eq!(response.status, 303, "adding a key should redirect");
}

fn create_repository(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the diff snapshot tests"),
            ("visibility", visibility),
        ],
    );
    assert_eq!(
        response.status, 303,
        "creating a repository should redirect"
    );
}

fn ssh_command(key: &Path, port: u16) -> String {
    format!(
        "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes \
         -o PreferredAuthentications=publickey \
         -o StrictHostKeyChecking=accept-new \
         -o UserKnownHostsFile=/dev/null -p {port}",
        key.display()
    )
}

fn git(dir: &Path, key: &Path, port: u16, args: &[&str]) -> Output {
    Command::new("git")
        .current_dir(dir)
        .arg("-c")
        .arg("user.name=Test")
        .arg("-c")
        .arg("user.email=test@example.org")
        .args(args)
        .env("GIT_SSH_COMMAND", ssh_command(key, port))
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("git should run")
}

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),
    );
}

fn url(port: u16, owner: &str, slug: &str) -> String {
    format!("ssh://git@127.0.0.1:{port}/{owner}/{slug}.git")
}

fn tooling_present() -> bool {
    fn have(binary: &str, arg: &str) -> bool {
        Command::new(binary).arg(arg).output().is_ok()
    }
    have("git", "--version") && have("ssh", "-V") && have("ssh-keygen", "--version")
}

macro_rules! skip_without_tooling {
    () => {
        if !tooling_present() {
            eprintln!("skipping: this test needs `git`, `ssh` and `ssh-keygen` on PATH");
            return;
        }
    };
}

#[test]
fn merged_patch_diff_uses_snapshot() {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("snapshot-merge");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes the repository with a main branch
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob clones and creates a feature branch with a patch
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "new feature content\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "bob committing",
    );

    // Bob pushes to refs/patches
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // Verify the patch was created
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert_eq!(patch_page.status, 200);
    assert!(patch_page.body.contains("Add feature"));

    // Alice merges the patch
    let merge_response = alice.post_form("/~alice/test-repo/patches/1/merge", &[]);
    assert_eq!(merge_response.status, 303);

    // Verify the patch is now merged
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert_eq!(patch_page.status, 200);
    assert!(patch_page.body.contains("merged"));

    // The diff should still show the changes, even after merge
    assert!(patch_page.body.contains("feature.txt"));
    assert!(patch_page.body.contains("new feature content"));
}

#[test]
fn closed_patch_diff_uses_snapshot() {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("snapshot-close");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes the repository
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates a patch
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "rejected content\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add rejected feature"],
        ),
        "bob committing",
    );

    // Bob pushes to refs/patches
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // Bob closes his own patch
    let close_response = bob.post_form(
        "/~alice/test-repo/patches/1",
        &[
            ("summary", "Add rejected feature"),
            ("description", ""),
            ("target_branch", "main"),
            ("status", "closed"),
        ],
    );
    assert_eq!(close_response.status, 303);

    // Verify the patch is now closed
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert_eq!(patch_page.status, 200);
    assert!(patch_page.body.contains("closed"));

    // The diff should still show the changes
    assert!(patch_page.body.contains("feature.txt"));
    assert!(patch_page.body.contains("rejected content"));
}

#[test]
fn reopened_patch_clears_snapshot() {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("snapshot-reopen");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes the repository
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates a patch
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "initial content\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "bob committing",
    );

    // Bob pushes to refs/patches
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // Bob closes the patch
    let close_response = bob.post_form(
        "/~alice/test-repo/patches/1",
        &[
            ("summary", "Add feature"),
            ("description", ""),
            ("target_branch", "main"),
            ("status", "closed"),
        ],
    );
    assert_eq!(close_response.status, 303);

    // Verify it's closed
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert!(patch_page.body.contains("closed"));

    // Bob reopens the patch
    let reopen_response = bob.post_form(
        "/~alice/test-repo/patches/1",
        &[
            ("summary", "Add feature"),
            ("description", ""),
            ("target_branch", "main"),
            ("status", "open"),
        ],
    );
    assert_eq!(reopen_response.status, 303);

    // Verify it's open again
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert!(patch_page.body.contains("open"));

    // The diff should still show the changes (live diff, not snapshot)
    assert!(patch_page.body.contains("feature.txt"));
    assert!(patch_page.body.contains("initial content"));
}

#[test]
fn snapshot_captures_exact_state() {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("snapshot-exact");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes the repository
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates a patch with initial content
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "original content\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "bob committing",
    );

    // Bob pushes to refs/patches
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // Alice merges the patch
    let merge_response = alice.post_form("/~alice/test-repo/patches/1/merge", &[]);
    assert_eq!(merge_response.status, 303);

    // Verify the merged patch shows "original content"
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert_eq!(patch_page.status, 200);
    assert!(patch_page.body.contains("original content"));

    // Bob pushes more changes to the patch branch after merge
    fs::write(bob_clone.join("feature.txt"), "updated content\n").expect("writing updated feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging updated",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Update feature"],
        ),
        "bob committing updated",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", "HEAD:refs/patches"],
        ),
        "bob pushing updated to refs/patches",
    );

    // The patch page should still show the original merged content, not the new
    // changes
    let patch_page = alice.get("/~alice/test-repo/patches/1");
    assert_eq!(patch_page.status, 200);
    assert!(patch_page.body.contains("original content"));
    // The snapshot should not contain the post-merge changes
    assert!(!patch_page.body.contains("updated content"));
}
