//! The compare view: shows the diff between two revisions (branches, tags, or
//! commits).

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

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

/// Names the scratch directories, so several tests can run at once.
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);
    (server, port)
}

/// Registers `name` through the form, which logs them in.
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
    );
}

/// Creates a repository through the form, and checks it landed where it should.
fn create(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the compare tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating `{owner}/{slug}` should redirect\n{}",
        response.body
    );
}

/// A directory of the test's own, removed when it goes out of scope.
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);
    }
}

/// Runs `git` in `dir` with the test's own identity, with `env` set for this
/// call - the dated commits the listing order is asserted against.
fn git(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> Output {
    let mut command = Command::new("git");
    command
        .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");
    for (name, value) in env {
        command.env(name, value);
    }

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

/// Fills `owner/slug`'s bare repository with a diverged history:
/// - c1 "Add readme": readme.txt = "hello\n" (main)
/// - c2 "Add a line": append "world\n" (main); lightweight tag `v1` at c2
/// - branch `feat` at c2: c3 "Fork change" (append "from feat\n" to
///   readme.txt), c4 "Add feature file" (feature.txt = "feature\n")
/// - back on main: c5 "Main moves on" (main_only.txt = "only on main\n")
/// - branch `feature/x` at c5: c6 "Slashed branch change" (slash.txt)
/// - `push --all` + `push --tags`
fn populate_with_branches(server: &Server, scratch: &Scratch, owner: &str, slug: &str) {
    let bare = server.repo_root().join(owner).join(format!("{slug}.git"));

    let work = scratch.path("work");
    fs::create_dir_all(&work).expect("the working tree should be creatable");
    assert_ok(
        &git(&work, &[], &["init", "-q", "-b", "main"]),
        "initialising",
    );

    // c1: Add readme
    fs::write(work.join("readme.txt"), "hello\n").expect("writing readme.txt");
    assert_ok(
        &git(&work, &[], &["add", "readme.txt"]),
        "adding readme.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "c1"]),
        "committing readme",
    );

    // c2: Add a line
    fs::write(work.join("readme.txt"), "hello\nworld\n").expect("modifying readme.txt");
    assert_ok(
        &git(&work, &[], &["add", "readme.txt"]),
        "adding modified readme.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "c2"]),
        "committing modification",
    );

    // Tag v1 at c2
    assert_ok(&git(&work, &[], &["tag", "v1"]), "creating tag v1");

    // Branch feat at c2
    assert_ok(
        &git(&work, &[], &["checkout", "-q", "-b", "feat"]),
        "creating feat branch",
    );

    // c3 on feat: Fork change
    fs::write(work.join("readme.txt"), "hello\nworld\nfrom feat\n")
        .expect("modifying readme on feat");
    assert_ok(
        &git(&work, &[], &["add", "readme.txt"]),
        "adding modified readme.txt on feat",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Fork change"]),
        "committing fork change",
    );

    // c4 on feat: Add feature file
    fs::write(work.join("feature.txt"), "feature\n").expect("writing feature.txt");
    assert_ok(
        &git(&work, &[], &["add", "feature.txt"]),
        "adding feature.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Add feature file"]),
        "committing feature file",
    );

    // Back on main
    assert_ok(
        &git(&work, &[], &["checkout", "-q", "main"]),
        "switching back to main",
    );

    // c5 on main: Main moves on
    fs::write(work.join("main_only.txt"), "only on main\n").expect("writing main_only.txt");
    assert_ok(
        &git(&work, &[], &["add", "main_only.txt"]),
        "adding main_only.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Main moves on"]),
        "committing main moves on",
    );

    // Branch feature/x at c5
    assert_ok(
        &git(&work, &[], &["checkout", "-q", "-b", "feature/x"]),
        "creating feature/x branch",
    );

    // c6 on feature/x: Slashed branch change
    fs::write(work.join("slash.txt"), "slash\n").expect("writing slash.txt");
    assert_ok(&git(&work, &[], &["add", "slash.txt"]), "adding slash.txt");
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Slashed branch change"]),
        "committing slashed branch change",
    );

    // Push all branches and tags
    assert_ok(
        &git(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]),
        "pushing all branches",
    );
    assert_ok(
        &git(
            &work,
            &[],
            &["push", "-q", "--tags", bare.to_str().unwrap()],
        ),
        "pushing all tags",
    );
}

#[tokio::test]
async fn a_compare_shows_what_head_brought() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-head-brought");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main...feat");
    assert_eq!(response.status, 200, "compare page");
    let body = &response.body;

    // Should contain feat-side changes
    assert!(
        body.contains("feature.txt"),
        "should show feature.txt, got:\n{body}"
    );
    assert!(
        body.contains("+from feat"),
        "should show +from feat, got:\n{body}"
    );

    // Should NOT contain main-side changes after the fork
    assert!(
        !body.contains("main_only.txt"),
        "should NOT show main_only.txt, got:\n{body}"
    );
    assert!(
        !body.contains("only on main"),
        "should NOT show 'only on main', got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_compare_lists_the_commits_in_the_range() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-commits-range");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main...feat");
    assert_eq!(response.status, 200, "compare page");
    let body = &response.body;

    // Should contain feat-side commits
    assert!(
        body.contains("Fork change"),
        "should show 'Fork change', got:\n{body}"
    );
    assert!(
        body.contains("Add feature file"),
        "should show 'Add feature file', got:\n{body}"
    );

    // Should NOT contain commits before the fork or on main after the fork
    assert!(
        !body.contains("Add readme"),
        "should NOT show 'Add readme', got:\n{body}"
    );
    assert!(
        !body.contains("Main moves on"),
        "should NOT show 'Main moves on', got:\n{body}"
    );

    // Should link to commit pages
    assert!(
        body.contains("/~alice/test-repo/commit/"),
        "should link to commits, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn comparing_a_revision_with_itself_shows_no_changes() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-self");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main...main");
    assert_eq!(response.status, 200, "compare page");
    let body = &response.body;

    // Should show empty state
    assert!(
        body.contains("No differences"),
        "should show 'No differences', got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn an_unknown_end_is_not_found() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-unknown");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main...deadbeef");
    assert_eq!(response.status, 404, "unknown head should be 404");

    let response = alice.get("/~alice/test-repo/compare/deadbeef...main");
    assert_eq!(response.status, 404, "unknown base should be 404");

    Ok(())
}

#[tokio::test]
async fn a_two_dot_range_is_refused() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-two-dot");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main..feat");
    assert_eq!(response.status, 404, "two-dot range should be refused");

    Ok(())
}

#[tokio::test]
async fn a_malformed_range_is_not_found() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-malformed");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main");
    assert_eq!(response.status, 404, "missing separator should be 404");

    let response = alice.get("/~alice/test-repo/compare/main...");
    assert_eq!(response.status, 404, "empty head should be 404");

    Ok(())
}

#[tokio::test]
async fn an_end_starting_with_a_dash_is_refused() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-dash");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/-HEAD...main");
    assert_eq!(response.status, 404, "dash-starting base should be refused");

    let response = alice.get("/~alice/test-repo/compare/main...-HEAD");
    assert_eq!(response.status, 404, "dash-starting head should be refused");

    Ok(())
}

#[tokio::test]
async fn a_tag_can_be_one_end() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-tag");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/v1...feat");
    assert_eq!(response.status, 200, "compare page with tag");
    let body = &response.body;

    // Should show feat-side changes
    assert!(
        body.contains("feature.txt"),
        "should show feature.txt, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_branch_with_a_slash_compares() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-slash");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/main...feature/x");
    assert_eq!(response.status, 200, "compare page with slashed branch");
    let body = &response.body;

    // Should show feature/x changes
    assert!(
        body.contains("slash.txt"),
        "should show slash.txt, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn unrelated_histories_have_no_common_ancestor() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-orphan");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    // Create an orphan branch
    let bare = server.repo_root().join("alice").join("test-repo.git");
    let work = scratch.path("work2");
    fs::create_dir_all(&work).expect("the working tree should be creatable");
    assert_ok(
        &git(&work, &[], &["init", "-q", "-b", "orphan"]),
        "initialising",
    );
    fs::write(work.join("orphan.txt"), "orphan\n").expect("writing orphan.txt");
    assert_ok(
        &git(&work, &[], &["add", "orphan.txt"]),
        "adding orphan.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "orphan"]),
        "committing orphan",
    );
    assert_ok(
        &git(
            &work,
            &[],
            &["push", "-q", bare.to_str().unwrap(), "orphan"],
        ),
        "pushing orphan branch",
    );

    let response = alice.get("/~alice/test-repo/compare/main...orphan");
    assert_eq!(response.status, 404, "unrelated histories should be 404");

    Ok(())
}

#[tokio::test]
async fn a_private_repository_is_not_shown_to_anonymous_users() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-private");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "private");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    // Anonymous client should get 404
    let mut anonymous = Client::new(port);
    let response = anonymous.get("/~alice/test-repo/compare/main...feat");
    assert_eq!(
        response.status, 404,
        "anonymous should get 404 for a private repo"
    );

    Ok(())
}

#[tokio::test]
async fn the_compare_landing_offers_a_picker() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-picker");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare");
    assert_eq!(response.status, 200, "compare landing");
    let body = &response.body;

    // Should have form with selects
    assert!(
        body.contains("select name=\"from\""),
        "should have from select, got:\n{body}"
    );
    assert!(
        body.contains("select name=\"to\""),
        "should have to select, got:\n{body}"
    );

    // Should have branch options
    assert!(
        body.contains(">main<"),
        "should have main option, got:\n{body}"
    );
    assert!(
        body.contains(">feat<"),
        "should have feat option, got:\n{body}"
    );
    assert!(
        body.contains(">feature/x<"),
        "should have feature/x option, got:\n{body}"
    );

    // Should have tag option
    assert!(body.contains(">v1<"), "should have v1 option, got:\n{body}");

    // Should have Compare tab in nav
    assert!(
        body.contains("Compare"),
        "should have Compare tab, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn the_picker_redirects_to_the_canonical_address() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-redirect");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare?from=main&to=feat");
    assert_eq!(response.status, 303, "should redirect");
    assert_eq!(
        response.location.as_deref(),
        Some("/~alice/test-repo/compare/main...feat"),
        "should redirect to canonical address"
    );

    Ok(())
}

#[tokio::test]
async fn the_picker_refuses_unusable_ends() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-refuse");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    // Missing 'to' parameter
    let response = alice.get("/~alice/test-repo/compare?from=main");
    assert_eq!(response.status, 200, "should show picker with error");
    assert!(
        response.body.contains("error"),
        "should show error, got:\n{}",
        response.body
    );

    // Invalid 'from' parameter
    let response = alice.get("/~alice/test-repo/compare?from=-x&to=main");
    assert_eq!(response.status, 200, "should show picker with error");
    assert!(
        response.body.contains("error"),
        "should show error, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn the_trailing_slash_landing_matches_the_bare_one() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("compare-trailing-slash");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_branches(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/compare/");
    assert_eq!(response.status, 200, "trailing slash should work");

    Ok(())
}

#[tokio::test]
async fn an_empty_repository_has_nothing_to_compare() -> miette::Result<()> {
    let (server, port) = spawn();
    let _scratch = Scratch::new("compare-empty");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    // Don't populate - leave repo empty

    let response = alice.get("/~alice/test-repo/compare");
    assert_eq!(response.status, 200, "empty repo compare");
    let body = &response.body;

    // Should show "Nothing to compare yet."
    assert!(
        body.contains("Nothing to compare yet"),
        "should show empty message, got:\n{body}"
    );

    Ok(())
}
