//! Smart HTTP git protocol handlers.
//!
//! Provides handlers for `info/refs` (ref advertisement) and `git-upload-pack`
//! (pack negotiation and transport), enabling `git clone` and `git fetch` over
//! HTTP without CGI.
//!
//! Both handlers shell out to `git upload-pack --stateless-rpc`, following the
//! same streaming pattern as the archive handler.

use std::io;
use std::process::Stdio;

use async_compression::tokio::bufread::GzipDecoder;
use axum::body::Body;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::http::header;
use axum::response::IntoResponse;
use futures::StreamExt;
use serde::Deserialize;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio_util::io::{ReaderStream, StreamReader};

use crate::error::{Error, Result};
use crate::git;
use crate::routes::{AppState, RepoName};

use super::archive::reaper;

/// Query parameters for `info/refs`.
#[derive(Deserialize)]
pub(super) struct InfoRefsQuery {
    service: Option<String>,
}

/// Encodes a string as a pkt-line (4-digit hex length + payload).
fn pkt_line(s: &str) -> Vec<u8> {
    let len = s.len() + 4;
    format!("{len:04x}{s}").into_bytes()
}

/// Handles `GET /{repo}/info/refs?service=git-upload-pack`.
///
/// Returns a ref advertisement so clients can discover branches and tags before
/// fetching.  The response is a pkt-line stream with a `# service=git-upload-pack`
/// header, followed by the output of `git upload-pack --stateless-rpc --advertise-refs`.
pub async fn info_refs(
    State(state): State<AppState>,
    RepoName(repo): RepoName,
    headers: HeaderMap,
    Query(query): Query<InfoRefsQuery>,
) -> Result<impl IntoResponse> {
    let _service = query
        .service
        .filter(|s| s == "git-upload-pack")
        .ok_or_else(|| Error::BadRequest("unknown or missing service".into()))?;

    let repo_path = state.root.join(&repo);
    if !repo_path.exists() {
        return Err(Error::RepoNotFound(repo));
    }

    let _git_repo = git::open_repo(&state.root, &repo)?;

    let git_protocol = headers
        .get("git-protocol")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    let mut cmd = Command::new("git");
    cmd.arg("upload-pack")
        .arg("--stateless-rpc")
        .arg("--advertise-refs")
        .arg(repo_path.as_os_str());
    if let Some(protocol) = &git_protocol {
        cmd.env("GIT_PROTOCOL", protocol);
    }
    let output = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .map_err(Error::Io)?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::GitCorrupt(format!(
            "git upload-pack --advertise-refs failed: {stderr}"
        )));
    }

    let mut buf = Vec::new();
    buf.extend_from_slice(&pkt_line("# service=git-upload-pack\n"));
    buf.extend_from_slice(b"0000");
    buf.extend_from_slice(&output.stdout);

    Ok((
        [
            (
                header::CONTENT_TYPE,
                "application/x-git-upload-pack-advertisement",
            ),
            (header::CACHE_CONTROL, "no-cache"),
        ],
        buf,
    ))
}

/// Handles `POST /{repo}/git-upload-pack`.
///
/// Processes a pack-request body (wants / haves) and streams back the generated
/// pack file.  The response is a pkt-line stream with status/progress messages
/// in band 2 and pack data in band 1.
///
/// The request body is streamed through an optional gzip decoder directly into
/// the child process's stdin — no single buffer holds the entire payload.
pub async fn upload_pack(
    State(state): State<AppState>,
    RepoName(repo): RepoName,
    headers: HeaderMap,
    body: Body,
) -> Result<impl IntoResponse> {
    let content_type = headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    if content_type != "application/x-git-upload-pack-request" {
        return Err(Error::BadRequest(
            "expected application/x-git-upload-pack-request".into(),
        ));
    }

    let git_protocol = headers
        .get("git-protocol")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    let content_encoding = headers
        .get(header::CONTENT_ENCODING)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    // Reject unsupported encodings before spawning child/spawning tasks.
    if let Some(ref enc) = content_encoding && enc != "gzip" {
        return Err(Error::BadRequest(format!(
            "unsupported content encoding: {enc}"
        )));
    }

    let repo_path = state.root.join(&repo);
    if !repo_path.exists() {
        return Err(Error::RepoNotFound(repo));
    }

    let _git_repo = git::open_repo(&state.root, &repo)?;

    let mut cmd = Command::new("git");
    cmd.arg("upload-pack")
        .arg("--stateless-rpc")
        .arg(repo_path.as_os_str());
    if let Some(protocol) = &git_protocol {
        cmd.env("GIT_PROTOCOL", protocol);
    }
    let mut child = cmd
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()
        .map_err(Error::Io)?;

    let mut stdin = child.stdin.take().expect("stdin configured");
    let stdout = child.stdout.take().expect("stdout configured");
    let stderr = child.stderr.take().expect("stderr configured");

    // Pipe request body (optionally decompressed) into git's stdin.
    tokio::spawn(async move {
        use tokio::io::AsyncWriteExt;

        let stream = body.into_data_stream();
        let mapped = stream.map(|r| r.map_err(io::Error::other));
        let mut body_reader = StreamReader::new(mapped);

        let result = if content_encoding.as_deref() == Some("gzip") {
            let mut decoder = GzipDecoder::new(BufReader::new(body_reader));
            tokio::io::copy(&mut decoder, &mut stdin).await
        } else {
            tokio::io::copy(&mut body_reader, &mut stdin).await
        };

        if let Err(e) = result {
            tracing::warn!(error = %e, "pipe body -> git stdin");
        }
        let _ = stdin.shutdown().await;
    });

    tokio::spawn(reaper(child, stderr, "git upload-pack"));

    let stream = ReaderStream::new(stdout);
    Ok((
        [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")],
        Body::from_stream(stream),
    ))
}

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

    #[test]
    fn pkt_line_hello() {
        assert_eq!(pkt_line("hello"), b"0009hello");
    }

    #[test]
    fn pkt_line_empty() {
        assert_eq!(pkt_line(""), b"0004");
    }

    #[test]
    fn pkt_line_single_char() {
        assert_eq!(pkt_line("x"), b"0005x");
    }
}
