//! A repository's files: shown on their own page with the language they are
//! written in coloured, and served raw for anything that wants the bytes.
//!
//! As with the other repository tests, the content is put there with a real
//! `git` pushed straight into the bare repository the server created.

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 file 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 one file of each kind the file
/// pages treat differently - source, plain text, markdown, binary - and a
/// branch whose name holds a slash with a file in it, then returns `main`'s
/// tip hash short and full: the short one is what a listing shows, the full
/// one is what its tree address is built from.
fn populate(server: &Server, scratch: &Scratch, owner: &str, slug: &str) -> (String, String) {
    let bare = server.repo_root().join(owner).join(format!("{slug}.git"));

    let work = scratch.path("work");
    fs::create_dir_all(work.join("src")).expect("the directory should be creatable");
    fs::create_dir_all(work.join("docs")).expect("the directory should be creatable");

    assert_ok(&git(&work, &["init", "-q", "-b", "main"]), "initialising");

    fs::write(work.join("README.md"), "# Notes\n").expect("writing a file");
    fs::write(work.join("notes.txt"), "plain notes\n").expect("writing a file");
    fs::write(
        work.join("src/lib.rs"),
        "pub fn lib() -> u32 {\n    42\n}\n",
    )
    .expect("writing a file");
    fs::write(
        work.join("docs/guide.md"),
        "# Guide\n\nSome **bold** words.\n",
    )
    .expect("writing a file");
    // Bytes no text file would hold, which is what makes it binary.
    fs::write(
        work.join("blob.bin"),
        [0x00u8, 0x01, 0x02, 0xff, 0x00, 0x7f],
    )
    .expect("writing a file");

    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Root commit"]),
        "committing",
    );

    // A branch whose name holds a slash, so a file's address has to keep it
    // one ref rather than read it as a directory path.
    assert_ok(
        &git(&work, &["checkout", "-q", "-b", "feature/x"]),
        "branching",
    );
    fs::create_dir_all(work.join("pkg")).expect("the directory should be creatable");
    fs::write(work.join("pkg/tool.py"), "print('tool')\n").expect("writing a file");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Add the tool"]),
        "committing",
    );

    assert_ok(
        &git(&work, &["checkout", "-q", "main"]),
        "returning to main",
    );
    assert_ok(
        &git(&work, &["push", "-q", "--all", bare.to_str().unwrap()]),
        "pushing the branches",
    );

    let short = git(&work, &["rev-parse", "--short", "HEAD"]);
    assert_ok(&short, "reading the short hash");
    let full = git(&work, &["rev-parse", "HEAD"]);
    assert_ok(&full, "reading the full hash");

    (
        String::from_utf8_lossy(&short.stdout).trim().to_string(),
        String::from_utf8_lossy(&full.stdout).trim().to_string(),
    )
}

/// The repository tab bar out of a page's body.
fn tab_bar(body: &str) -> &str {
    let start = body
        .find("<nav class=\"repository-tabs\">")
        .expect("the page should carry the repository tab bar");
    let rest = &body[start..];
    let end = rest.find("</nav>").expect("the tab bar should be closed") + "</nav>".len();
    &rest[..end]
}

#[tokio::test]
async fn a_source_file_is_shown_coloured() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("file-source");
    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/tree/main/src/lib.rs");
    assert_eq!(response.status, 200, "file page");
    let body = &response.body;

    // The language is coloured on the server, which is why the page needs
    // no script to show it.
    assert!(
        body.contains("<span style=\""),
        "the source should be coloured, got:\n{body}"
    );
    assert!(body.contains("lib"), "the source itself, got:\n{body}");

    // The breadcrumb ends on the file, the browse tab is still the page's,
    // and the raw answer is one link away.
    assert!(
        body.contains("<span class=\"current\">lib.rs</span>"),
        "the breadcrumb should end on the file, got:\n{body}"
    );
    assert!(
        body.contains(r#"<a href="/~alice/test-repo/raw/main/src/lib.rs">Raw</a>"#),
        "the raw link, got:\n{body}"
    );
    let bar = tab_bar(body);
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo/tree/main" aria-current="page">Browse</a>"#),
        "the browse tab should be current, got:\n{bar}"
    );

    // And a file on a branch whose name holds a slash, where the address
    // has to keep the ref whole.
    let response = alice.get("/~alice/test-repo/tree/feature/x/pkg/tool.py");
    assert_eq!(response.status, 200, "slashy ref file");
    let body = &response.body;
    assert!(
        body.contains("<span style=\""),
        "the source should be coloured, got:\n{body}"
    );
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/feature/x\">feature/x</a> / \
             <a href=\"/~alice/test-repo/tree/feature/x/pkg\">pkg</a> / \
             <span class=\"current\">tool.py</span></p>"
        ),
        "the breadcrumb should keep the ref whole, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_plain_text_file_is_shown_as_it_is() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("file-plain");
    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/tree/main/notes.txt");
    assert_eq!(response.status, 200, "plain text file");
    assert!(
        response.body.contains("plain notes"),
        "the text itself, got:\n{}",
        response.body
    );
    // Check that the source code section doesn't have syntax highlighting spans
    let source_section = response
        .body
        .split(r#"<pre class="source">"#)
        .nth(1)
        .expect("should have source section");
    let source_content = source_section
        .split("</pre>")
        .next()
        .expect("should have closing pre tag");
    assert!(
        !source_content.contains("<span style="),
        "text with no language has nothing to colour, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn a_markdown_file_is_rendered() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("file-markdown");
    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/tree/main/docs/guide.md");
    assert_eq!(response.status, 200, "markdown file");
    let body = &response.body;

    // Rendered the way a readme is, syntax and all, rather than shown as
    // the markup it is written in.
    assert!(body.contains("<h1>Guide</h1>"), "the heading, got:\n{body}");
    assert!(
        body.contains("<strong>bold</strong>"),
        "the emphasis, got:\n{body}"
    );
    assert!(
        !body.contains("**bold**"),
        "the markdown source should not be what a reader sees, got:\n{body}"
    );
    assert!(
        body.contains(r#"<a href="/~alice/test-repo/raw/main/docs/guide.md">Raw</a>"#),
        "the raw link to the source, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_binary_file_is_said_to_be_binary() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("file-binary");
    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/tree/main/blob.bin");
    assert_eq!(response.status, 200, "binary file");
    assert!(
        response.body.contains("binary"),
        "the page should say the file is binary, got:\n{}",
        response.body
    );
    // Check that the empty file message section doesn't have syntax
    // highlighting spans
    let empty_section = response
        .body
        .split(r#"<p class="empty">"#)
        .nth(1)
        .expect("should have empty section");
    let empty_content = empty_section
        .split("</p>")
        .next()
        .expect("should have closing p tag");
    assert!(
        !empty_content.contains("<span style="),
        "binary is not source to colour, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn the_raw_endpoint_serves_the_file_itself() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("file-raw");
    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/raw/main/notes.txt");
    assert_eq!(response.status, 200, "raw file");
    assert_eq!(
        response.body, "plain notes\n",
        "the bytes git holds, unchanged"
    );
    assert!(
        response
            .content_type
            .as_deref()
            .is_some_and(|kind| kind.starts_with("text/plain")),
        "a text file should be served as text, got {:?}",
        response.content_type
    );

    Ok(())
}

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

    // `feature/x` is one branch, even though the address splits at the first
    // slash: `pkg/tool.py` is the path within it, not within a branch called
    // `feature`.
    let response = alice.get("/~alice/test-repo/raw/feature/x/pkg/tool.py");
    assert_eq!(
        response.status, 200,
        "raw file on a branch whose name holds a slash"
    );
    assert_eq!(response.body, "print('tool')\n", "the bytes git holds");

    // The branch's own root is still a directory, slashed ref or not.
    assert_eq!(
        alice.get("/~alice/test-repo/raw/feature/x").status,
        404,
        "a directory is not a file, whatever the ref is called"
    );

    Ok(())
}

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

    // The tree address of a commit hash walks into it and reads its files,
    // the same as any branch's - which is what a listing's hash link leads
    // to.
    let response = alice.get(&format!("/~alice/test-repo/tree/{head}/src/lib.rs"));
    assert_eq!(response.status, 200, "file at a commit");
    assert!(
        response.body.contains("<span style=\""),
        "the source should be coloured, got:\n{}",
        response.body
    );
    assert!(
        response
            .body
            .contains("<span class=\"current\">lib.rs</span>"),
        "the breadcrumb should end on the file, got:\n{}",
        response.body
    );

    let response = alice.get(&format!("/~alice/test-repo/raw/{head}/notes.txt"));
    assert_eq!(response.status, 200, "raw file at a commit");
    assert_eq!(response.body, "plain notes\n", "the bytes git holds");

    Ok(())
}

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

    let mut stranger = Client::new(port);
    assert_eq!(
        stranger.get("/~alice/secret/tree/main/notes.txt").status,
        404,
        "a stranger should not reach the file page"
    );
    assert_eq!(
        stranger.get("/~alice/secret/raw/main/notes.txt").status,
        404,
        "a stranger should not reach the raw file"
    );

    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    assert_eq!(
        bob.get("/~alice/secret/raw/main/notes.txt").status,
        404,
        "another user should not reach the raw file either"
    );

    assert_eq!(
        alice.get("/~alice/secret/tree/main/notes.txt").status,
        200,
        "the owner should reach the file page"
    );
    assert_eq!(
        alice.get("/~alice/secret/raw/main/notes.txt").status,
        200,
        "the owner should reach the raw file"
    );

    Ok(())
}

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

    assert_eq!(
        alice.get("/~alice/test-repo/raw/main/src").status,
        404,
        "a directory is not a file"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/raw/main").status,
        404,
        "a whole revision is not a file either"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/raw/nothing/x").status,
        404,
        "neither a ref nor a revision"
    );

    Ok(())
}
