use std::{
    ffi::{OsStr, OsString},
    path::{Component, Path, PathBuf},
};

use serde::{Deserialize, Serialize};

use crate::{
    bare::GitBare,
    errors::{GitError, GitResult},
};

const DANGEROUS_SUBCOMMANDS: &[&str] = &[
    "add",
    "apply",
    "archive",
    "bisect",
    "bundle",
    "checkout",
    "clean",
    "clone",
    "commit",
    "config",
    "fetch",
    "gc",
    "hook",
    "init",
    "maintenance",
    "merge",
    "mv",
    "notes",
    "pull",
    "push",
    "rebase",
    "remote",
    "repack",
    "replace",
    "reset",
    "restore",
    "rm",
    "send-email",
    "sparse-checkout",
    "stash",
    "submodule",
    "switch",
    "update-index",
    "update-ref",
    "worktree",
];

const DANGEROUS_OPTIONS: &[&str] = &[
    "-C",
    "-c",
    "--exec-path",
    "--git-dir",
    "--html-path",
    "--man-path",
    "--namespace",
    "--paginate",
    "--super-prefix",
    "--upload-pack",
    "--work-tree",
];

const DANGEROUS_OPTION_PREFIXES: &[&str] = &[
    "--exec-path=",
    "--git-dir=",
    "--namespace=",
    "--super-prefix=",
    "--upload-pack=",
    "--work-tree=",
];

const DENIED_ENV_NAMES: &[&str] = &[
    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
    "GIT_CONFIG",
    "GIT_CONFIG_COUNT",
    "GIT_CONFIG_GLOBAL",
    "GIT_CONFIG_NOSYSTEM",
    "GIT_CONFIG_SYSTEM",
    "GIT_DIR",
    "GIT_EXEC_PATH",
    "GIT_EXTERNAL_DIFF",
    "GIT_INDEX_FILE",
    "GIT_OBJECT_DIRECTORY",
    "GIT_SSH",
    "GIT_SSH_COMMAND",
    "GIT_WORK_TREE",
    "LD_LIBRARY_PATH",
    "PATH",
];

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitCommandParams {
    pub args: Vec<String>,
    pub env: Vec<(String, String)>,
    pub stdin: Option<Vec<u8>>,
    pub check_status: bool,
    pub bypass_subcommand_validation: bool,
}

impl GitCommandParams {
    pub fn new(args: Vec<String>) -> Self {
        Self {
            args,
            env: Vec::new(),
            stdin: None,
            check_status: true,
            bypass_subcommand_validation: false,
        }
    }

    pub fn unchecked(mut self) -> Self {
        self.check_status = false;
        self
    }

    pub fn trusted(mut self) -> Self {
        self.bypass_subcommand_validation = true;
        self
    }

    pub fn with_stdin(mut self, stdin: Vec<u8>) -> Self {
        self.stdin = Some(stdin);
        self
    }

    pub fn with_env(mut self, name: String, value: String) -> Self {
        self.env.push((name, value));
        self
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitCommandOutput {
    pub status_code: Option<i32>,
    pub success: bool,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
}

impl GitCommandOutput {
    pub fn stdout_lossy(&self) -> String {
        String::from_utf8_lossy(&self.stdout).into_owned()
    }

    pub fn stderr_lossy(&self) -> String {
        String::from_utf8_lossy(&self.stderr).into_owned()
    }
}

impl GitBare {
    pub fn git_command(
        &self,
        args: Vec<String>,
    ) -> GitResult<GitCommandOutput> {
        self.git_command_with(GitCommandParams::new(args))
    }

    pub fn git_command_with(
        &self,
        params: GitCommandParams,
    ) -> GitResult<GitCommandOutput> {
        let bare_dir = self.safe_bare_dir()?;
        validate_git_command(&params)?;

        let mut args = Vec::with_capacity(params.args.len() + 2);
        args.push(OsString::from("--git-dir"));
        args.push(bare_dir.as_os_str().to_os_string());
        args.extend(params.args.iter().map(OsString::from));

        let mut expression = duct::cmd("git", args)
            .dir(&bare_dir)
            .stdout_capture()
            .stderr_capture()
            .env("GIT_CONFIG_NOSYSTEM", "1")
            .env("GIT_TERMINAL_PROMPT", "0")
            .env_remove("GIT_DIR")
            .env_remove("GIT_WORK_TREE")
            .env_remove("GIT_INDEX_FILE")
            .env_remove("GIT_OBJECT_DIRECTORY")
            .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
            .env_remove("GIT_SSH")
            .env_remove("GIT_SSH_COMMAND")
            .unchecked();

        for (name, value) in &params.env {
            expression = expression.env(name, value);
        }

        if let Some(stdin) = params.stdin {
            expression = expression.stdin_bytes(stdin);
        }

        let output = expression.run()?;
        let result = GitCommandOutput {
            status_code: output.status.code(),
            success: output.status.success(),
            stdout: output.stdout,
            stderr: output.stderr,
        };

        if params.check_status && !result.success {
            return Err(GitError::CommandFailed {
                status_code: result.status_code,
                stderr: result.stderr_lossy(),
            });
        }

        Ok(result)
    }

    pub fn git_command_stdout(&self, args: Vec<String>) -> GitResult<String> {
        let output = self.git_command(args)?;
        Ok(output.stdout_lossy())
    }

    pub fn git_command_success(&self, args: Vec<String>) -> GitResult<bool> {
        let output =
            self.git_command_with(GitCommandParams::new(args).unchecked())?;
        Ok(output.success)
    }

    pub fn git_command_trusted(
        &self,
        args: Vec<String>,
    ) -> GitResult<GitCommandOutput> {
        self.git_command_with(GitCommandParams::new(args).trusted())
    }

    pub fn git_command_trusted_unchecked(
        &self,
        args: Vec<String>,
    ) -> GitResult<GitCommandOutput> {
        self.git_command_with(GitCommandParams::new(args).trusted().unchecked())
    }

    pub fn git_command_trusted_stdout(
        &self,
        args: Vec<String>,
    ) -> GitResult<String> {
        let output =
            self.git_command_with(GitCommandParams::new(args).trusted())?;
        Ok(output.stdout_lossy())
    }

    pub fn git_command_trusted_success(
        &self,
        args: Vec<String>,
    ) -> GitResult<bool> {
        let output = self.git_command_with(
            GitCommandParams::new(args).trusted().unchecked(),
        )?;
        Ok(output.success)
    }

    fn safe_bare_dir(&self) -> GitResult<PathBuf> {
        let bare_dir = self.bare_dir.canonicalize()?;
        if !bare_dir.is_dir() {
            return Err(GitError::NotBareRepository);
        }

        Ok(bare_dir)
    }
}

fn validate_git_command(params: &GitCommandParams) -> GitResult<()> {
    if params.bypass_subcommand_validation {
        return Ok(());
    }

    if params.args.is_empty() {
        return Err(GitError::UnsafeCommand(
            "missing git subcommand".to_owned(),
        ));
    }

    let subcommand = first_subcommand(&params.args).ok_or_else(|| {
        GitError::UnsafeCommand("missing git subcommand".to_owned())
    })?;

    if DANGEROUS_SUBCOMMANDS.contains(&subcommand.as_str()) {
        return Err(GitError::UnsafeCommand(format!(
            "subcommand `{subcommand}` is not allowed"
        )));
    }

    for arg in &params.args {
        validate_git_arg(arg)?;
    }

    for (name, value) in &params.env {
        validate_git_env(name, value)?;
    }

    Ok(())
}

fn first_subcommand(args: &[String]) -> Option<String> {
    let mut iter = args.iter().peekable();
    while let Some(arg) = iter.next() {
        if arg == "--" {
            return None;
        }

        if arg == "-c"
            || arg == "-C"
            || arg == "--git-dir"
            || arg == "--work-tree"
        {
            iter.next();
            continue;
        }

        if arg.starts_with('-') {
            continue;
        }

        return Some(arg.to_owned());
    }

    None
}

fn validate_git_arg(arg: &str) -> GitResult<()> {
    if DANGEROUS_OPTIONS.contains(&arg) {
        return Err(GitError::UnsafeCommand(format!(
            "option `{arg}` is not allowed"
        )));
    }

    if DANGEROUS_OPTION_PREFIXES
        .iter()
        .any(|prefix| arg.starts_with(prefix))
    {
        return Err(GitError::UnsafeCommand(format!(
            "option `{arg}` is not allowed"
        )));
    }

    if let Some((name, value)) = arg.split_once('=') {
        if looks_like_path(value) {
            validate_relative_path(value).map_err(|_| {
                GitError::UnsafeCommand(format!(
                    "path value for `{name}` escapes bare_dir"
                ))
            })?;
        }
    } else if looks_like_path(arg) {
        validate_relative_path(arg).map_err(|_| {
            GitError::UnsafeCommand(format!(
                "path argument `{arg}` escapes bare_dir"
            ))
        })?;
    }

    if contains_shell_metachar(arg) {
        return Err(GitError::UnsafeCommand(format!(
            "argument `{arg}` contains shell metacharacters"
        )));
    }

    Ok(())
}

fn validate_git_env(name: &str, value: &str) -> GitResult<()> {
    let upper_name = name.to_ascii_uppercase();
    if DENIED_ENV_NAMES.contains(&upper_name.as_str())
        || upper_name.starts_with("GIT_CONFIG_")
    {
        return Err(GitError::UnsafeCommand(format!(
            "environment variable `{name}` is not allowed"
        )));
    }

    if looks_like_path(value) {
        validate_relative_path(value).map_err(|_| {
            GitError::UnsafeCommand(format!(
                "environment variable `{name}` escapes bare_dir"
            ))
        })?;
    }

    Ok(())
}

fn looks_like_path(value: &str) -> bool {
    value.contains('/') || value.contains('\\') || value == "." || value == ".."
}

fn validate_relative_path(value: &str) -> Result<(), ()> {
    let path = Path::new(value);
    if path.is_absolute() {
        return Err(());
    }

    for component in path.components() {
        match component {
            Component::Normal(part) if is_safe_path_part(part) => {}
            Component::CurDir => {}
            _ => return Err(()),
        }
    }

    Ok(())
}

fn is_safe_path_part(part: &OsStr) -> bool {
    let Some(part) = part.to_str() else {
        return false;
    };

    !part.is_empty() && part != "." && part != ".."
}

fn contains_shell_metachar(value: &str) -> bool {
    value.chars().any(|ch| {
        matches!(ch, ';' | '|' | '&' | '`' | '$' | '<' | '>' | '\n' | '\r')
    })
}
