//! What a repository is: the slug rules, the command a git client sends, the
//! directory the bare repository lives in, and who may reach it.
//!
//! Split from [`store`], which is the database side. This module is the rules
//! that hold either way - the pure shape of a repository address, and the
//! checks that keep a slug from climbing out of the repository root - so it is
//! where the unit tests live.

use std::path::{Path, PathBuf};
use std::process::Stdio;

use crate::entities::repositories::{MergePolicy, Visibility};
use crate::models::user::is_username;

pub mod git;
pub mod store;

/// The branch a new repository points `HEAD` at.
///
/// Pinned rather than inherited from the host's `init.defaultBranch`, which
/// git only defaults to `main` from 2.28 onward and otherwise still says
/// `master`.
pub const DEFAULT_BRANCH: &str = "main";

/// The longest a slug may be. It is one path segment and one URL segment, so
/// it has to stay readable as well as addressable.
pub const MAX_SLUG_LEN: usize = 64;

/// Names a slug may not take, because the webui answers something else at
/// `/~{user}/{slug}`.
///
/// A repository lives at `/~{user}/{slug}`, which is the same space the webui
/// puts its own pages in: `edit` edits the profile, `ssh-keys` manages the keys
/// that reach repositories, and `repositories` is where a new one is created. A
/// repository called any of those would be stored and pushed to over SSH, but
/// its page would be somebody else's form, and the clone URL on the profile
/// would point at the wrong place. So it cannot be created in the first place.
///
/// The ActivityPub endpoints also claim paths under `/~{user}/`: `inbox`,
/// `outbox`, `followers`, `notes`, and `activities`. A repository with any of
/// these names would collide with federation routes.
///
/// Every literal segment added under `/~{user}/` has to be added here too; the
/// routes that define them live in [`crate::webui`].
pub const RESERVED_SLUGS: &[&str] = &[
    "edit",
    "invites",
    "repositories",
    "ssh-keys",
    "inbox",
    "outbox",
    "followers",
    "notes",
    "activities",
    "discover",
    "follow",
];

/// Whether `slug` is a name the webui already answers.
///
/// Matched exactly, because that is how the router matches too: `/~user/EDIT`
/// reaches a repository, so `EDIT` is not reserved. This is a rule about
/// creating, not about addressing - an existing repository with a reserved name
/// (from before this rule, or written straight to the database) is still
/// served over SSH, where there is no such collision.
pub fn is_reserved(slug: &str) -> bool {
    RESERVED_SLUGS.contains(&slug)
}

/// Whether `slug` can name a repository.
///
/// Like a username it is a path segment and a URL segment, so it has to
/// survive both: no slash, no space, nothing a shell or a URL would eat. The
/// leading and trailing characters are restricted because a dot at the front
/// hides the directory and a dot at the end is silently dropped by some
/// filesystems.
pub fn is_slug(slug: &str) -> bool {
    let length = slug.chars().count();
    (1..=MAX_SLUG_LEN).contains(&length)
        && slug.chars().all(|character| {
            character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
        })
        && !slug.starts_with('.')
        && !slug.ends_with('.')
        // `.git` is the suffix the URL adds, so a slug ending in it would be
        // reached as `name.git.git`.
        && !slug.to_ascii_lowercase().ends_with(".git")
}

/// What a git client asked the server to run over SSH.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitService {
    /// `git-upload-pack`: clone, fetch, and `ls-remote`.
    UploadPack,
    /// `git-receive-pack`: push.
    ReceivePack,
}

impl GitService {
    /// The `git` subcommand that serves this service.
    pub fn subcommand(self) -> &'static str {
        match self {
            GitService::UploadPack => "upload-pack",
            GitService::ReceivePack => "receive-pack",
        }
    }
}

impl std::fmt::Display for GitService {
    /// The command as a git client names it, so a log line reads like the
    /// request it is about.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            GitService::UploadPack => "git-upload-pack",
            GitService::ReceivePack => "git-receive-pack",
        })
    }
}

/// A repository addressed as `<owner>/<slug>`.
///
/// Both halves have been checked against their rules before one of these is
/// built, which is what lets [`RepositoryPath::bare_path`] join them onto a
/// root without worrying about `..`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryPath {
    pub owner: String,
    pub slug: String,
}

impl RepositoryPath {
    /// Parses the path half of a git command.
    ///
    /// Git sends the path as it appeared in the URL, so it arrives as
    /// `/owner/slug.git` for `ssh://` URLs and as `owner/slug.git` for the
    /// `git@host:owner/slug.git` shorthand. Either shape resolves to the same
    /// repository; a `~` before the owner is also accepted, the way
    /// `ssh://host/~owner/repo` has always worked.
    pub fn parse(path: &str) -> Option<Self> {
        let path = path.strip_prefix('/').unwrap_or(path);
        let path = path.strip_prefix('~').unwrap_or(path);

        let (owner, slug) = path.split_once('/')?;
        // A further slash would be a nested path, which is not how a
        // repository is addressed here.
        if slug.contains('/') {
            return None;
        }

        let slug = slug.strip_suffix(".git")?;
        if !is_username(owner) || !is_slug(slug) {
            return None;
        }

        Some(RepositoryPath {
            owner: owner.to_string(),
            slug: slug.to_string(),
        })
    }

    /// The directory the bare repository lives in, under `root`.
    ///
    /// Safe to join because both halves were checked against their rules by
    /// [`RepositoryPath::parse`].
    pub fn bare_path(&self, root: &Path) -> PathBuf {
        root.join(&self.owner).join(format!("{}.git", self.slug))
    }
}

impl std::fmt::Display for RepositoryPath {
    /// `owner/slug`, the way the repository is addressed everywhere but on
    /// disk.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.owner, self.slug)
    }
}

/// Why a command a git client sent cannot be honoured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandError {
    /// Not `git-upload-pack` or `git-receive-pack`.
    Unsupported,
    /// The command is one we run, but names no repository of ours.
    Unusable,
}

/// Splits the command a git client runs over SSH into the service it wants and
/// the repository it names.
///
/// Git sends this as the whole of an SSH `exec` request - the entire command
/// line, quoted the way a shell would quote it. It is never handed to a shell
/// here, so the quoting is stripped deliberately rather than interpreted:
/// exactly one command word and exactly one path argument, nothing else.
pub fn parse_git_command(command: &str) -> Result<(GitService, RepositoryPath), CommandError> {
    let mut parts = command.split_whitespace();

    let service = match parts.next() {
        Some("git-upload-pack") => GitService::UploadPack,
        Some("git-receive-pack") => GitService::ReceivePack,
        _ => return Err(CommandError::Unsupported),
    };

    let argument = parts.next().ok_or(CommandError::Unusable)?;
    // Anything after the path is an argument we do not understand, so it is
    // refused rather than guessed at.
    if parts.next().is_some() {
        return Err(CommandError::Unsupported);
    }

    let path = unquote(argument);
    let path = RepositoryPath::parse(path).ok_or(CommandError::Unusable)?;

    Ok((service, path))
}

/// Strips the single layer of quoting around `argument`.
///
/// Git quotes the path with single quotes because the command is nominally
/// passed to a shell, and that is the only shape it ever sends. Anything with
/// different quoting is left alone rather than repaired - the path rules reject
/// it, which is the point.
fn unquote(argument: &str) -> &str {
    argument
        .strip_prefix('\'')
        .and_then(|rest| rest.strip_suffix('\''))
        .unwrap_or(argument)
}

/// Whether `viewer` may read from a repository.
///
/// A public repository is readable by anyone at all, signed in or not, because
/// nothing here is served over SSH anonymously but a profile is public. A
/// private one is its owner's alone: `viewer` is the id of whoever is asking,
/// or `None` for somebody signed out.
pub fn may_read(visibility: Visibility, owner_id: i64, viewer: Option<i64>) -> bool {
    match visibility {
        Visibility::Public => true,
        Visibility::Private => viewer == Some(owner_id),
    }
}

/// Whether `viewer_id` may write to a repository.
///
/// Only its owner pushes. There are no collaborators yet.
pub fn may_write(owner_id: i64, viewer_id: i64) -> bool {
    owner_id == viewer_id
}

/// The visibility a form selected.
///
/// Anything that is not exactly `private` reads as public, matching the
/// column's own default, so a missing or garbled field cannot silently land a
/// repository somewhere nobody can then reach it.
pub fn visibility_from_form(value: &str) -> Visibility {
    match value {
        "private" => Visibility::Private,
        _ => Visibility::Public,
    }
}

impl Visibility {
    /// The word to show a reader for this visibility.
    pub fn label(self) -> &'static str {
        match self {
            Visibility::Public => "public",
            Visibility::Private => "private",
        }
    }
}

/// The merge policy a form selected.
///
/// Anything that does not match a known policy returns `None`, so an invalid
/// or missing value cannot silently change a repository's policy.
pub fn merge_policy_from_form(value: &str) -> Option<MergePolicy> {
    match value {
        "prefer-fast-forward" => Some(MergePolicy::PreferFastForward),
        "fast-forward-only" => Some(MergePolicy::FastForwardOnly),
        "merge-commit-only" => Some(MergePolicy::MergeCommitOnly),
        "rebase-then-fast-forward" => Some(MergePolicy::RebaseThenFastForward),
        _ => None,
    }
}

impl MergePolicy {
    /// The word to show a reader for this merge policy.
    pub fn label(self) -> &'static str {
        match self {
            MergePolicy::PreferFastForward => "prefer-fast-forward",
            MergePolicy::FastForwardOnly => "fast-forward-only",
            MergePolicy::MergeCommitOnly => "merge-commit-only",
            MergePolicy::RebaseThenFastForward => "rebase-then-fast-forward",
        }
    }

    /// A human-readable description of this merge policy.
    pub fn description(self) -> &'static str {
        match self {
            MergePolicy::PreferFastForward => "Prefer fast-forward",
            MergePolicy::FastForwardOnly => "Fast-forward only",
            MergePolicy::MergeCommitOnly => "Merge commit only",
            MergePolicy::RebaseThenFastForward => "Rebase then fast-forward",
        }
    }
}

/// Creates the bare repository at `path`, with `HEAD` pointing at `main`.
///
/// `--bare` rather than a working tree: the repository is only ever read and
/// written by git itself, and a bare repository cannot have its working tree
/// and index committed to by accident.
#[tracing::instrument(level = "debug")]
pub async fn init_bare(path: &Path) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }

    let mut command = tokio::process::Command::new("git");
    command
        .arg("init")
        .arg("--bare")
        .arg(format!("--initial-branch={DEFAULT_BRANCH}"))
        .arg(path)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());

    let output = command.output().await?;
    if output.status.success() {
        return Ok(());
    }

    Err(std::io::Error::other(format!(
        "git init --bare {} failed: {}",
        path.display(),
        String::from_utf8_lossy(&output.stderr).trim_end()
    )))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn path(owner: &str, slug: &str) -> RepositoryPath {
        RepositoryPath {
            owner: owner.to_string(),
            slug: slug.to_string(),
        }
    }

    #[test]
    fn slug_rules() {
        for slug in [
            "notes",
            "a",
            "my-repo_2",
            "notes.v2",
            "a".repeat(64).as_str(),
        ] {
            assert!(is_slug(slug), "{slug:?} should be a valid slug");
        }

        for slug in [
            "",
            "has space",
            "has/slash",
            ".hidden",
            "trailing.",
            "notes.git",
            "NOTES.GIT",
            "a".repeat(65).as_str(),
            "back\\slash",
        ] {
            assert!(!is_slug(slug), "{slug:?} should be refused");
        }
    }

    #[test]
    fn reserved_names_are_slugs_but_not_available() {
        // Every reserved name has to be a slug the rules would otherwise let
        // through, or reserving it would be hiding a shape error instead.
        for reserved in RESERVED_SLUGS {
            assert!(
                is_slug(reserved),
                "{reserved:?} is reserved but would not be a valid slug anyway"
            );
            assert!(is_reserved(reserved), "{reserved:?} should be reserved");
        }

        // And the names around them are still free.
        for free in [
            "edit-notes",
            "ssh_keys",
            "edits",
            "repositories2",
            "EDIT",
            "my-notes",
        ] {
            assert!(!is_reserved(free), "{free:?} should not be reserved");
        }
    }

    #[test]
    fn path_parsing_accepts_both_shapes() {
        // `ssh://host/owner/slug.git`
        assert_eq!(
            RepositoryPath::parse("/paul/notes.git"),
            Some(path("paul", "notes"))
        );
        // `git@host:owner/slug.git`
        assert_eq!(
            RepositoryPath::parse("paul/notes.git"),
            Some(path("paul", "notes"))
        );
        // `ssh://host/~owner/slug.git`
        assert_eq!(
            RepositoryPath::parse("/~paul/notes.git"),
            Some(path("paul", "notes"))
        );
    }

    #[test]
    fn path_parsing_refuses_anything_else() {
        for bad in [
            // Not two components.
            "",
            "/",
            "notes.git",
            "/paul/",
            // More than two components.
            "/paul/team/notes.git",
            // No `.git`.
            "/paul/notes",
            // A slug that would not be a slug.
            "/paul/.hidden.git",
            "/paul/notes.GIT.git",
            // An owner that would not be a username.
            "/pau l/notes.git",
            "/../notes.git",
        ] {
            assert!(
                RepositoryPath::parse(bad).is_none(),
                "{bad:?} should not parse as a repository path"
            );
        }
    }

    #[test]
    fn a_traversal_cannot_be_addressed() {
        // `..` is not a valid owner or slug, so it never reaches the
        // filesystem as anything but a name.
        assert_eq!(RepositoryPath::parse("/../notes.git"), None);
        assert_eq!(RepositoryPath::parse("/paul/../notes.git"), None);
    }

    #[test]
    fn command_parsing() {
        let (service, repo) = parse_git_command("git-upload-pack '/paul/notes.git'")
            .expect("a fetch command should parse");
        assert_eq!(service, GitService::UploadPack);
        assert_eq!(repo, path("paul", "notes"));

        let (service, _) = parse_git_command("git-receive-pack 'paul/notes.git'")
            .expect("a push command should parse");
        assert_eq!(service, GitService::ReceivePack);

        // Unquoted, the way some clients send it.
        assert_eq!(
            parse_git_command("git-upload-pack /paul/notes.git"),
            Ok((GitService::UploadPack, path("paul", "notes")))
        );
    }

    #[test]
    fn command_parsing_refuses_anything_else() {
        for bad in [
            "",
            "sh -c 'rm -rf /'",
            "git-upload-pack",
            "git-upload-pack a b",
            "git-shell",
            // Double-quoted instead, which git does not send but a client
            // could.
            "git-upload-pack \"/paul/notes.git\"",
            // A path that names no repository of ours.
            "git-upload-pack '/paul/../notes.git'",
            "git-receive-pack '/paul/notes'",
        ] {
            assert!(parse_git_command(bad).is_err(), "{bad:?} should be refused");
        }
    }

    #[test]
    fn bare_path_is_rooted_under_the_owner() {
        let repo = path("paul", "notes");
        assert_eq!(
            repo.bare_path(Path::new("/repos")),
            PathBuf::from("/repos/paul/notes.git")
        );
    }

    #[test]
    fn reading_is_public_but_writing_is_the_owners() {
        let owner = 1;
        let stranger = 2;

        assert!(may_read(Visibility::Public, owner, Some(stranger)));
        assert!(may_read(Visibility::Public, owner, None));
        assert!(!may_read(Visibility::Private, owner, Some(stranger)));
        assert!(!may_read(Visibility::Private, owner, None));
        assert!(may_read(Visibility::Private, owner, Some(owner)));

        assert!(may_write(owner, owner));
        assert!(!may_write(owner, stranger));
    }
}
