//! Archive download handler.
//!
//! Covers the route `/{repo}/archive/{format}/{*ref}`.  Streams the
//! repository archive in the requested format (`tar.gz`, `tgz`, `tar.xz`,
//! `txz`, or `zip`) for the given ref.  `tar.xz` and `txz` are produced by
//! piping `git archive --format=tar` through an external `xz` process.

use std::process::Stdio;

use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::HeaderValue;
use axum::http::header;
use axum::response::IntoResponse;
use serde::Deserialize;
use tokio::io::AsyncRead;
use tokio::process::{Child, Command};
use tokio_util::io::ReaderStream;

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

/// Path parameters for the archive route.
#[derive(Deserialize)]
pub(super) struct ArchivePath {
    format: String,
    #[serde(rename = "ref")]
    ref_: String,
}

struct ArchiveConfig {
    content_type: &'static str,
    extension: &'static str,
    git_format: &'static str,
    uses_xz: bool,
}

/// Returns the archive configuration for a given format string.
/// Returns `None` for unsupported formats.
fn archive_config(format: &str) -> Option<ArchiveConfig> {
    match format {
        "tar.gz" | "tgz" => Some(ArchiveConfig {
            content_type: "application/gzip",
            extension: "tar.gz",
            git_format: "tar.gz",
            uses_xz: false,
        }),
        "tar.xz" | "txz" => Some(ArchiveConfig {
            content_type: "application/x-xz",
            extension: "tar.xz",
            git_format: "tar",
            uses_xz: true,
        }),
        "zip" => Some(ArchiveConfig {
            content_type: "application/zip",
            extension: "zip",
            git_format: "zip",
            uses_xz: false,
        }),
        _ => None,
    }
}

/// Spawns `git archive` and streams its stdout directly into the response body.
fn stream_archive(repo_path: &str, git_format: &str, ref_: &str) -> Result<Body> {
    let mut child = Command::new("git")
        .arg("-C")
        .arg(repo_path)
        .arg("archive")
        .arg(format!("--format={git_format}"))
        .arg(ref_)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(Error::Io)?;

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

    tokio::spawn(reaper(child, stderr, "git archive"));

    let stream = ReaderStream::new(stdout);
    Ok(Body::from_stream(stream))
}

/// Pipes `git archive --format=tar` through `xz` and streams the compressed
/// output.
///
/// Tokio's `ChildStdout` does not implement `Into<Stdio>`, so we cannot use
/// the direct `.stdin(git_stdout)` approach that `std::process::Command`
/// supports.  Instead we relay data between the two processes in a background
/// task via `tokio::io::copy`.
fn stream_archive_xz(repo_path: &str, ref_: &str) -> Result<Body> {
    let mut git = Command::new("git")
        .arg("-C")
        .arg(repo_path)
        .arg("archive")
        .arg("--format=tar")
        .arg(ref_)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(Error::Io)?;

    let git_stdout = git.stdout.take().expect("stdout configured");
    let git_stderr = git.stderr.take().expect("stderr configured");

    let mut xz = Command::new("xz")
        .arg("-c")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(Error::Io)?;

    let mut xz_stdin = xz.stdin.take().expect("stdin configured");
    let xz_stdout = xz.stdout.take().expect("stdout configured");
    let xz_stderr = xz.stderr.take().expect("stderr configured");

    // Relay git's stdout to xz's stdin in a background task.
    tokio::spawn(async move {
        use tokio::io::AsyncWriteExt;
        if let Err(e) =
            tokio::io::copy(&mut tokio::io::BufReader::new(git_stdout), &mut xz_stdin).await
        {
            eprintln!("pipe git->xz: {e}");
        }
        let _ = xz_stdin.shutdown().await;
        // Reap git (its stdout is fully consumed now).
        reaper(git, git_stderr, "git archive").await;
    });

    tokio::spawn(reaper(xz, xz_stderr, "xz compression"));

    let stream = ReaderStream::new(xz_stdout);
    Ok(Body::from_stream(stream))
}

/// Background task that reads stderr and waits for the child process to exit.
/// Errors are printed to stderr — by this point we have already started
/// streaming the response and cannot change the HTTP status.
async fn reaper(mut child: Child, mut stderr: impl AsyncRead + Unpin, label: &'static str) {
    use tokio::io::AsyncReadExt;
    let mut buf = String::new();
    let _ = stderr.read_to_string(&mut buf).await;
    let ok = child.wait().await.is_ok_and(|s| s.success());
    if !ok || !buf.is_empty() {
        eprintln!("{label} failed: {buf}");
    }
}

/// Streams a repository archive for the given ref in the requested format.
pub async fn handler(
    State(state): State<AppState>,
    RepoName(name): RepoName,
    Path(params): Path<ArchivePath>,
) -> Result<impl IntoResponse> {
    if params.ref_.is_empty() {
        return Err(Error::BadRequest("empty ref".into()));
    }

    let cfg = archive_config(&params.format)
        .ok_or_else(|| Error::BadRequest(format!("unsupported format: {}", params.format)))?;

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

    // Verify the ref resolves before shelling out.
    let git_repo = git::open_repo(&state.root, &name)?;
    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {
        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));
    }

    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.
    let short_ref = params
        .ref_
        .strip_prefix("refs/heads/")
        .or_else(|| params.ref_.strip_prefix("refs/tags/"))
        .unwrap_or(&params.ref_);
    let filename = format!("{name}-{short_ref}.{}", cfg.extension);
    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
        .map_err(|_| Error::BadRequest("invalid header value".into()))?;

    let content_type = HeaderValue::from_static(cfg.content_type);

    let repo_path = repo_path.to_string_lossy().into_owned();
    let archive_ref = params.ref_.clone();

    let body = if cfg.uses_xz {
        stream_archive_xz(&repo_path, &archive_ref)?
    } else {
        stream_archive(&repo_path, cfg.git_format, &archive_ref)?
    };

    Ok((
        [
            (header::CONTENT_TYPE, content_type),
            (header::CONTENT_DISPOSITION, disposition),
        ],
        body,
    ))
}
