//! Action to download Python packages using `pip download`.

#![allow(clippy::result_large_err)]

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use tempfile::tempdir;

use crate::{
    action::{ActionError, Context},
    action_impl::ActionImpl,
    qemu,
    runlog::RunLogSource,
    util::{copy_file, mkdir, UtilError},
};

const SUBDIR: &str = "pip";
const DEFAULT_REQUIREMENTS: &str = "requirements.txt";

/// Download Python packages using `pip download`.
///
/// This action copies only the requirements file from the source tree
/// (never the full source), then runs `pip download` to fetch packages
/// into the dependencies directory. By default, only pre-built wheels
/// are downloaded (`--only-binary=:all:`), which avoids executing any
/// `setup.py` from the project or its dependencies.
///
/// Set `allow_sdist` to `true` to also allow source distributions.
/// Even with source distributions, `pip download` only downloads and
/// does not build or install.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipDownload {
    /// Requirements file name, relative to source root.
    /// Default: "requirements.txt".
    #[serde(default)]
    requirements: Option<String>,

    /// Allow source distributions (not just wheels). Default: false.
    #[serde(default)]
    allow_sdist: Option<bool>,
}

impl PipDownload {
    /// Create a new `PipDownload` action.
    pub fn new(requirements: Option<String>, allow_sdist: Option<bool>) -> Self {
        Self {
            requirements,
            allow_sdist,
        }
    }

    fn requirements_file(&self) -> &str {
        self.requirements.as_deref().unwrap_or(DEFAULT_REQUIREMENTS)
    }

    fn allow_sdist(&self) -> bool {
        self.allow_sdist.unwrap_or(false)
    }
}

impl ActionImpl for PipDownload {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        let pip_dir = context.deps_dir().join(SUBDIR);
        mkdir(&pip_dir).map_err(|err| PipError::Mkdir(pip_dir.clone(), err))?;

        // Copy only the requirements file to a temp dir, like cargo_fetch
        // strips the source tree. This avoids exposing any project code
        // to pip.
        let tmp = tempdir().map_err(PipError::TempDir)?;
        let src_req = context.source_dir().join(self.requirements_file());
        let tmp_req = tmp.path().join(self.requirements_file());
        copy_file(&src_req, &tmp_req).map_err(PipError::CopyRequirements)?;

        let pip_dir_str = pip_dir.to_string_lossy().to_string();
        let tmp_req_str = tmp_req.to_string_lossy().to_string();

        let mut argv: Vec<&str> = vec!["pip", "download", "--dest", &pip_dir_str, "--no-deps"];

        if !self.allow_sdist() {
            argv.push("--only-binary=:all:");
        }

        argv.push("-r");
        argv.push(&tmp_req_str);

        context
            .runlog()
            .debug(RunLogSource::PrePlan, format!("pip_download: {argv:?}"));

        super::spawn_in(context, &argv, tmp.path().to_path_buf())?;

        // Set environment variables so the VM plan finds the downloaded
        // packages without needing network access.
        let vm_pip_dir = format!("{}/{SUBDIR}", qemu::DEPS_DIR);
        context.set_plan_env("PIP_FIND_LINKS", &vm_pip_dir);
        context.set_plan_env("PIP_NO_INDEX", "1");

        Ok(())
    }
}

/// Errors from PipDownload action.
#[derive(Debug, thiserror::Error)]
pub enum PipError {
    /// Can't create the pip dependencies directory.
    #[error("failed to create pip dependencies directory {0}")]
    Mkdir(PathBuf, #[source] UtilError),

    /// Can't create a temporary directory.
    #[error("failed to create temporary directory")]
    TempDir(#[source] std::io::Error),

    /// Can't copy requirements file.
    #[error("failed to copy requirements file")]
    CopyRequirements(#[source] UtilError),
}

impl From<PipError> for ActionError {
    fn from(value: PipError) -> Self {
        Self::Pip(value)
    }
}
