//! A repository's landing page, and the read-only git access it offers.
//!
//! The page is served by the real binary, and the content behind it is put
//! there with a real `git`, pushed straight into the bare repository. The
//! HTTP clone at the end drives a real `git` client against the server, so
//! what is checked is that the two sides agree rather than that a particular
//! sequence of bytes was assembled.

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

/// Fills `owner/slug`'s bare repository with commits, a readme and a tag, by
/// pushing straight into it.
///
/// The bare path is the one the server itself created, so this is the same
/// repository a visitor's page reads - not a copy.
fn populate(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");
    fs::write(work.join("README.md"), "# Notes\n\nWelcome to *notes*.\n")
        .expect("writing the readme");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Initial commit"]),
        "committing",
    );

    fs::write(
        work.join("README.fr.md"),
        "# Notes\n\nBienvenue dans *notes*.\n",
    )
    .expect("writing the French readme");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Add a French readme"]),
        "committing",
    );

    assert_ok(
        &git(&work, &["tag", "-a", "v0.1", "-m", "First release"]),
        "tagging",
    );

    assert_ok(
        &git(&work, &["push", "-q", bare.to_str().unwrap(), "main"]),
        "pushing the commits",
    );
    assert_ok(
        &git(&work, &["push", "-q", bare.to_str().unwrap(), "--tags"]),
        "pushing the tags",
    );
}

/// The abbreviated hash of the tip commit of the repository `populate` built.
fn head_hash(scratch: &Scratch) -> String {
    let work = scratch.path("work");
    let output = git(&work, &["rev-parse", "--short", "HEAD"]);
    assert_ok(&output, "reading the head hash");
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

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

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

    assert!(
        body.contains("Welcome to <em>notes</em>."),
        "the readme should be rendered from markdown, got:\n{body}"
    );

    assert!(
        body.contains("main"),
        "the branch should be listed, got:\n{body}"
    );
    assert!(
        body.contains("Add a French readme"),
        "the branch's latest commit subject should be listed, got:\n{body}"
    );
    assert!(
        body.contains("Test"),
        "the commit's author should be listed, got:\n{body}"
    );

    let head = head_hash(&scratch);
    assert!(
        body.contains(&head),
        "the commit hash {head} should be listed, got:\n{body}"
    );

    assert!(
        body.contains("v0.1"),
        "the tag should be listed, got:\n{body}"
    );

    Ok(())
}

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

    // A browser asking for French gets the French readme.
    let response = alice.get_with("/~alice/test-repo", &[("Accept-Language", "fr")]);
    assert_eq!(response.status, 200, "repository page");
    assert!(
        response.body.contains("Bienvenue dans <em>notes</em>."),
        "a French visitor should get the French readme, got:\n{}",
        response.body
    );
    assert!(
        !response.body.contains("Welcome to <em>notes</em>."),
        "the plain readme should have been passed over, got:\n{}",
        response.body
    );

    // One asking for a language nobody wrote for gets the plain readme.
    let response = alice.get_with("/~alice/test-repo", &[("Accept-Language", "de")]);
    assert!(
        response.body.contains("Welcome to <em>notes</em>."),
        "an unwritten language should fall back to the plain readme, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn the_clone_url_suits_the_visitor() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("landing-clone-url");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    // Signed out, the read-only HTTP URL.
    let anonymous = Client::new(port).get("/~alice/test-repo");
    assert_eq!(anonymous.status, 200, "repository page");
    assert!(
        anonymous
            .body
            .contains(&format!("http://127.0.0.1:{port}/alice/test-repo.git")),
        "a signed-out visitor should be offered the HTTP URL, got:\n{}",
        anonymous.body
    );
    assert!(
        !anonymous.body.contains("ssh://"),
        "a signed-out visitor should not be offered an SSH URL, got:\n{}",
        anonymous.body
    );

    // Signed in, the read-write SSH URL.
    let response = alice.get("/~alice/test-repo");
    assert!(
        response.body.contains("ssh://git@"),
        "a signed-in visitor should be offered the SSH URL, got:\n{}",
        response.body
    );

    Ok(())
}

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

    let destination = scratch.path("clone");
    let url = format!("http://127.0.0.1:{port}/alice/test-repo.git");
    let output = git(
        &scratch.dir,
        &["clone", &url, destination.to_str().unwrap()],
    );
    assert_ok(&output, "cloning over HTTP");

    assert_eq!(
        fs::read_to_string(destination.join("README.md")).expect("the readme should be there"),
        "# Notes\n\nWelcome to *notes*.\n",
        "the clone should carry the pushed content"
    );

    Ok(())
}

#[tokio::test]
async fn http_git_access_is_read_only() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("landing-read-only");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    // Asking which refs may be written is answered before git is consulted.
    let response = Client::new(port).get("/alice/test-repo.git/info/refs?service=git-receive-pack");
    assert_eq!(
        response.status, 403,
        "a push's ref advertisement should be refused\n{}",
        response.body
    );

    // A client that posts a push anyway is refused too.
    let mut anonymous = Client::new(port);
    let response = anonymous.post_form("/alice/test-repo.git/git-receive-pack", &[]);
    assert_eq!(
        response.status, 403,
        "a push should be refused over HTTP\n{}",
        response.body
    );

    Ok(())
}

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

    // Its page is hidden, as before.
    assert_eq!(
        Client::new(port).get("/~alice/secret").status,
        404,
        "a stranger should not reach the page"
    );

    // And so is git access, which answers as though the repository is not
    // there rather than admitting it is private.
    let response = Client::new(port).get("/alice/secret.git/info/refs?service=git-upload-pack");
    assert_eq!(
        response.status, 404,
        "a stranger should not be told the private repository exists\n{}",
        response.body
    );

    let destination = scratch.path("clone");
    let url = format!("http://127.0.0.1:{port}/alice/secret.git");
    let output = git(
        &scratch.dir,
        &["clone", &url, destination.to_str().unwrap()],
    );
    assert!(
        !output.status.success(),
        "a stranger should not clone a private repository\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}

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

    let response = alice.get("/~alice/test-repo");
    assert_eq!(response.status, 200, "empty repository page");
    // Nothing has been pushed, so both preview lists would be empty, and a
    // section with nothing under its heading is not shown at all.
    assert!(
        !response.body.contains("<h3>Branches</h3>") && !response.body.contains("<h3>Tags</h3>"),
        "an empty repository should show no branch or tag sections, got:\n{}",
        response.body
    );

    Ok(())
}

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

    let response = alice.get("/~alice/test-repo");
    assert_eq!(response.status, 200, "repository page");
    assert!(
        !response.body.contains("class=\"readme\""),
        "nothing has been pushed, so there is no readme to show, got:\n{}",
        response.body
    );

    Ok(())
}
