pub(crate) mod install_backend;

use crate::PlatformTag;
use crate::PythonInterpreter;
use crate::Target;
use crate::auditwheel::AuditWheelMode;
use crate::build_options::CargoOptions;
use crate::compression::CompressionOptions;
use crate::target::detect_arch_from_python;
use crate::{
    BuildContext, BuildOptions, BuildOrchestrator, OutputOptions, PlatformOptions, PythonOptions,
};
use anyhow::{Context, Result, anyhow, bail, ensure};
use cargo_options::heading;
use fs_err as fs;
use install_backend::{
    InstallBackend, check_pip_exists, find_uv_bin, find_uv_python, is_pixi_venv, is_uv_venv,
};
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::str;
use tempfile::TempDir;
use tracing::{debug, instrument};
use url::Url;

/// Install the crate as module in the current virtualenv
#[derive(Debug, clap::Parser)]
pub struct DevelopOptions {
    /// Which kind of bindings to use
    #[arg(
        short = 'b',
        long = "bindings",
        alias = "binding-crate",
        value_parser = ["pyo3", "pyo3-ffi", "cffi", "uniffi", "bin"]
    )]
    pub bindings: Option<String>,
    /// Pass --release to cargo
    #[arg(short = 'r', long, help_heading = heading::COMPILATION_OPTIONS, conflicts_with = "profile")]
    pub release: bool,
    /// Strip the library for minimum file size
    #[arg(long)]
    pub strip: bool,
    /// Install extra requires aka. optional dependencies
    ///
    /// Use as `--extras=extra1,extra2`
    #[arg(
        short = 'E',
        long,
        value_delimiter = ',',
        action = clap::ArgAction::Append
    )]
    pub extras: Vec<String>,
    /// Install dependency groups defined in pyproject.toml (PEP 735)
    ///
    /// Use as `--group=test,docs`
    #[arg(
        short = 'G',
        long,
        value_delimiter = ',',
        action = clap::ArgAction::Append
    )]
    pub group: Vec<String>,
    /// Skip installation, only build the extension module inplace
    ///
    /// Only works with mixed Rust/Python project layout
    #[arg(long)]
    pub skip_install: bool,
    /// Use a specific pip installation instead of the default one.
    ///
    /// This can be used to supply the path to a pip executable when the
    /// current virtualenv does not provide one.
    #[arg(long)]
    pub pip_path: Option<PathBuf>,
    /// `cargo rustc` options
    #[command(flatten)]
    pub cargo_options: CargoOptions,
    /// Use `uv` to install packages instead of `pip`
    #[arg(long)]
    pub uv: bool,

    /// Wheel compression options
    #[command(flatten)]
    pub compression: CompressionOptions,

    /// Auto generate Python type stubs by introspecting the binary. Requires PyO3 and its "experimental-inspect" feature
    #[arg(long)]
    pub generate_stubs: bool,
}

#[instrument(skip_all)]
fn install_dependencies(
    build_context: &BuildContext,
    extras: &[String],
    groups: &[String],
    python: &Path,
    venv_dir: &Path,
    install_backend: &InstallBackend,
) -> Result<()> {
    if !build_context.project.metadata24.requires_dist.is_empty() {
        let mut extra_names = Vec::with_capacity(extras.len());
        for extra in extras {
            extra_names.push(
                pep508_rs::ExtraName::new(extra.clone())
                    .with_context(|| format!("invalid extra name: {extra}"))?,
            );
        }
        let mut args = vec!["install".to_string()];
        args.extend(
            build_context
                .project
                .metadata24
                .requires_dist
                .iter()
                .map(|x| {
                    let mut pkg = x.clone();
                    // Remove extra marker to make it installable with pip:
                    //
                    // * ` and extra == 'EXTRA_NAME'`
                    // * `; extra == 'EXTRA_NAME'`
                    //
                    // Keep in sync with `Metadata23::merge_pyproject_toml()`
                    pkg.marker = pkg.marker.simplify_extras(&extra_names);
                    pkg.to_string()
                }),
        );
        let status = install_backend
            .make_command(python)
            .args(&args)
            .env("VIRTUAL_ENV", venv_dir)
            .status()
            .with_context(|| format!("Failed to run {} install", install_backend.name()))?;
        if !status.success() {
            bail!(
                r#"{} install finished with "{}""#,
                install_backend.name(),
                status
            )
        }
    }
    let effective_groups = if groups.is_empty() {
        let has_dev_group = build_context
            .project
            .pyproject_toml
            .as_ref()
            .and_then(|p| p.dependency_groups.as_ref())
            .is_some_and(|dg| dg.0.contains_key("dev"));
        if has_dev_group {
            vec!["dev".to_string()]
        } else {
            Vec::new()
        }
    } else {
        groups.to_vec()
    };
    if !effective_groups.is_empty() {
        let mut args = vec!["install".to_string()];
        for group in &effective_groups {
            args.push("--group".to_string());
            args.push(group.clone());
        }
        let status = install_backend
            .make_command(python)
            .args(&args)
            .env("VIRTUAL_ENV", venv_dir)
            .status()
            .with_context(|| format!("Failed to run {} install --group", install_backend.name()))?;
        if !status.success() {
            bail!(
                r#"{} install --group finished with "{}""#,
                install_backend.name(),
                status
            )
        }
    }
    Ok(())
}

#[instrument(skip_all, fields(wheel_filename = %wheel_filename.display()))]
fn install_wheel(
    build_context: &BuildContext,
    python: &Path,
    venv_dir: &Path,
    wheel_filename: &Path,
    install_backend: &InstallBackend,
) -> Result<()> {
    let mut cmd = install_backend.make_command(python);
    let output = cmd
        .args(["install", "--no-deps", "--force-reinstall"])
        .arg(dunce::simplified(wheel_filename))
        .env("VIRTUAL_ENV", venv_dir)
        .output()
        .context(format!(
            "{} install failed (ran {:?} with {:?})",
            install_backend.name(),
            cmd.get_program(),
            &cmd.get_args().collect::<Vec<_>>(),
        ))?;
    if !output.status.success() {
        bail!(
            "{} install in {} failed running {:?}: {}\n--- Stdout:\n{}\n--- Stderr:\n{}\n---\n",
            install_backend.name(),
            venv_dir.display(),
            &cmd.get_args().collect::<Vec<_>>(),
            output.status,
            String::from_utf8_lossy(&output.stdout).trim(),
            String::from_utf8_lossy(&output.stderr).trim(),
        );
    }
    if !output.stderr.is_empty() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stderr = stderr.trim();
        if let Some(leftover) = detect_pip_in_use_leftover(stderr) {
            bail!(
                "{backend} could not overwrite the installed extension module because it is in use \
                 (most often by a Python process that has already imported the package).\n\
                 {backend} salvaged the install by renaming the old location to a leftover \
                 directory, but it could not delete that leftover — and the leftover still holds \
                 the OLD compiled code. If a Python process imports the package before the \
                 leftover is removed, the import system can resolve the stale copy and silently \
                 run pre-build code.\n\n\
                 To recover:\n  \
                 1. Stop every Python process that imported this package (this releases the file handle).\n  \
                 2. Delete the leftover directory: {leftover}\n  \
                 3. Re-run `maturin develop`.\n\n\
                 Original {backend} output:\n{stderr}",
                backend = install_backend.name(),
            );
        }
        if install_backend.stderr_indicates_problem() {
            eprintln!(
                "⚠️ Warning: {} raised a warning running {:?}:\n{}",
                install_backend.name(),
                &cmd.get_args().collect::<Vec<_>>(),
                stderr,
            );
        }
    }
    if let Err(err) = configure_as_editable(build_context, python, install_backend) {
        eprintln!("⚠️ Warning: failed to set package as editable: {err}");
    }
    Ok(())
}

/// Detect pip's "Failed to remove contents in a temporary directory" warning
/// when the leftover path looks like an in-use install rename (a basename
/// prefixed with `~`).
///
/// pip handles a locked install file by renaming the old directory to a sibling
/// whose basename is prefixed with `~`, then writing the new wheel files in
/// place. If pip later fails to remove that sibling — typically on Windows when
/// a running Python process still holds the `.pyd` open — it logs this warning
/// and exits with status 0. The leftover keeps the OLD compiled code, so a
/// subsequent import can silently load stale code instead of the freshly
/// installed module.
///
/// Returns the leftover path so callers can surface it to the user.
fn detect_pip_in_use_leftover(stderr: &str) -> Option<&str> {
    static RE: Lazy<Regex> = Lazy::new(|| {
        Regex::new(r"Failed to remove contents in a temporary directory '([^']+)'").unwrap()
    });
    let path = RE.captures(stderr)?.get(1)?.as_str();
    let basename = path.rsplit(['/', '\\']).next().unwrap_or(path);
    basename.starts_with('~').then_some(path)
}

/// Each editable-installed python package has a `direct_url.json` file that includes a `file://` URL
/// indicating the location of the source code of that project. The maturin import hook uses this
/// URL to locate and rebuild editable-installed projects.
///
/// When a maturin package is installed using `pip install -e`, pip takes care of writing the
/// correct URL, however when a maturin package is installed with `maturin develop`, the URL is
/// set to the path to the temporary wheel file created during installation.
#[instrument(skip_all)]
fn configure_as_editable(
    build_context: &BuildContext,
    python: &Path,
    install_backend: &InstallBackend,
) -> Result<()> {
    println!("✏️ Setting installed package as editable");
    install_backend.check_supports_show_files(python)?;
    let mut cmd = install_backend.make_command(python);
    let cmd = cmd.args(["show", "--files", &build_context.project.metadata24.name]);
    debug!("running {:?}", cmd);
    let output = cmd.output()?;
    ensure!(output.status.success(), "failed to list package files");
    if let Some(direct_url_path) = parse_direct_url_path(&String::from_utf8_lossy(&output.stdout))?
    {
        let project_dir = build_context
            .project
            .pyproject_toml_path
            .parent()
            .ok_or_else(|| anyhow!("failed to get project directory"))?;
        let uri = Url::from_file_path(project_dir)
            .map_err(|_| anyhow!("failed to convert project directory to file URL"))?;
        let content = format!("{{\"dir_info\": {{\"editable\": true}}, \"url\": \"{uri}\"}}");
        fs::write(direct_url_path, content)?;
    }
    Ok(())
}

fn parse_direct_url_path(pip_show_output: &str) -> Result<Option<PathBuf>> {
    static LOCATION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"Location: ([^\r\n]*)").unwrap());
    static DIRECT_URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"  (.*direct_url.json)").unwrap());

    if let Some(Some(location)) = LOCATION_RE.captures(pip_show_output).map(|c| c.get(1))
        && let Some(Some(direct_url_path)) =
            DIRECT_URL_RE.captures(pip_show_output).map(|c| c.get(1))
    {
        return Ok(Some(
            PathBuf::from(location.as_str()).join(direct_url_path.as_str()),
        ));
    }
    Ok(None)
}

/// Installs a crate by compiling it and copying the shared library to site-packages.
/// Also adds the dist-info directory to make sure pip and other tools detect the library
///
/// Works only in a virtualenv.
#[allow(clippy::too_many_arguments)]
pub fn develop(develop_options: DevelopOptions, venv_dir: &Path) -> Result<()> {
    let DevelopOptions {
        bindings,
        release,
        strip,
        extras,
        group,
        skip_install,
        pip_path,
        mut cargo_options,
        uv,
        compression,
        generate_stubs,
    } = develop_options;
    compression.validate();

    // set profile to release if specified; `--release` and `--profile` are mutually exclusive
    if release {
        cargo_options.profile = Some("release".to_string());
    }

    let mut target_triple = cargo_options.target.clone();
    let target = Target::from_target_triple(cargo_options.target.as_ref())?;
    let python = target.get_venv_python(venv_dir);

    // check python platform and architecture
    if !target.user_specified
        && let Some(detected_target) = detect_arch_from_python(&python, &target)
    {
        target_triple = Some(detected_target);
    }

    // Store wheel in a unique location so we don't get name clashes with parallel runs
    let wheel_dir = TempDir::new().context("Failed to create temporary directory")?;

    let build_options = BuildOptions {
        python: PythonOptions {
            interpreter: vec![python.clone()],
            find_interpreter: false,
            bindings,
        },
        platform: PlatformOptions {
            platform_tag: vec![PlatformTag::Linux],
            auditwheel: Some(AuditWheelMode::Skip),
            skip_auditwheel: false,
            #[cfg(feature = "zig")]
            zig: false,
        },
        output: OutputOptions {
            out: Some(wheel_dir.path().to_path_buf()),
            include_debuginfo: !strip && target.is_windows(),
            sbom_include: Vec::new(),
        },
        cargo: CargoOptions {
            target: target_triple,
            ..cargo_options
        },
        compression,
        generate_stubs,
    };

    let build_context = build_options
        .into_build_context()
        .strip(if strip { Some(true) } else { None })
        .editable(true)
        .build()?;

    // Ensure that version information is present, https://github.com/PyO3/maturin/issues/2416
    if build_context
        .project
        .pyproject_toml
        .as_ref()
        .is_some_and(|p| !p.warn_invalid_version_info())
    {
        bail!(
            "Cannot build without valid version information. \
               You need to specify either `project.version` or `project.dynamic = [\"version\"]` in pyproject.toml."
        );
    }

    let interpreter =
        PythonInterpreter::check_executable(&python, &target, build_context.project.bridge())?
            .ok_or_else(|| {
                anyhow!("Expected `python` to be a python interpreter inside a virtualenv ಠ_ಠ")
            })?;

    let uv_venv = is_uv_venv(venv_dir) || is_pixi_venv(venv_dir);
    let uv_info = if uv || uv_venv {
        match find_uv_python(&interpreter.executable).or_else(|_| find_uv_bin()) {
            Ok(uv_info) => Some(Ok(uv_info)),
            Err(e) => {
                if uv {
                    Some(Err(e))
                } else {
                    // Ignore error and try pip instead if it's a uv/pixi venv but `--uv` is not specified
                    None
                }
            }
        }
    } else {
        None
    };
    let install_backend = if let Some(uv_info) = uv_info {
        let (uv_path, uv_args) = uv_info?;
        InstallBackend::Uv {
            path: uv_path,
            args: uv_args,
        }
    } else {
        check_pip_exists(&interpreter.executable, pip_path.as_ref())
            .context("Failed to find pip (if working with a uv venv try `maturin develop --uv`)")?;
        InstallBackend::Pip {
            path: pip_path.clone(),
        }
    };

    if !skip_install {
        install_dependencies(
            &build_context,
            &extras,
            &group,
            &python,
            venv_dir,
            &install_backend,
        )?;
    }

    let orchestrator = BuildOrchestrator::new(&build_context);
    let wheels = orchestrator.build_wheels()?;
    if !skip_install {
        for (filename, _supported_version) in wheels.iter() {
            install_wheel(
                &build_context,
                &python,
                venv_dir,
                filename,
                &install_backend,
            )?;
            eprintln!(
                "🛠 Installed {}-{}",
                build_context.project.metadata24.name, build_context.project.metadata24.version
            );
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{detect_pip_in_use_leftover, parse_direct_url_path};

    #[test]
    fn test_detect_pip_in_use_leftover_windows() {
        let stderr = "WARNING: Failed to remove contents in a temporary directory \
            'C:\\Users\\me\\venv\\Lib\\site-packages\\~ackage'.\r\n  \
            You can safely remove it manually.";
        assert_eq!(
            detect_pip_in_use_leftover(stderr),
            Some("C:\\Users\\me\\venv\\Lib\\site-packages\\~ackage"),
        );
    }

    #[test]
    fn test_detect_pip_in_use_leftover_unix() {
        let stderr = "WARNING: Failed to remove contents in a temporary directory \
            '/home/me/venv/lib/python3.11/site-packages/~ackage'.\n  \
            You can safely remove it manually.";
        assert_eq!(
            detect_pip_in_use_leftover(stderr),
            Some("/home/me/venv/lib/python3.11/site-packages/~ackage"),
        );
    }

    #[test]
    fn test_detect_pip_in_use_leftover_ignores_non_tilde_temp_dir() {
        // Same warning text, but the leftover basename has no `~` prefix — this is
        // an unrelated cleanup hiccup, not an in-use install. Keep it as a warning.
        let stderr = "WARNING: Failed to remove contents in a temporary directory \
            '/tmp/pip-build-xyz'.\n  You can safely remove it manually.";
        assert_eq!(detect_pip_in_use_leftover(stderr), None);
    }

    #[test]
    fn test_detect_pip_in_use_leftover_ignores_unrelated_warnings() {
        let stderr = "WARNING: You are using pip version 20.0.0; \
            however, version 24.0 is available.";
        assert_eq!(detect_pip_in_use_leftover(stderr), None);
    }

    #[test]
    #[cfg(not(target_os = "windows"))]
    fn test_parse_direct_url() {
        let example_with_direct_url = "\
Name: my-project
Version: 0.1.0
Location: /foo bar/venv/lib/pythonABC/site-packages
Editable project location: /tmp/temporary.whl
Files:
  my_project-0.1.0+abc123de.dist-info/INSTALLER
  my_project-0.1.0+abc123de.dist-info/METADATA
  my_project-0.1.0+abc123de.dist-info/RECORD
  my_project-0.1.0+abc123de.dist-info/REQUESTED
  my_project-0.1.0+abc123de.dist-info/WHEEL
  my_project-0.1.0+abc123de.dist-info/direct_url.json
  my_project-0.1.0+abc123de.dist-info/entry_points.txt
  my_project.pth
";
        let expected_path = PathBuf::from(
            "/foo bar/venv/lib/pythonABC/site-packages/my_project-0.1.0+abc123de.dist-info/direct_url.json",
        );
        assert_eq!(
            parse_direct_url_path(example_with_direct_url).unwrap(),
            Some(expected_path)
        );

        let example_without_direct_url = "\
Name: my-project
Version: 0.1.0
Location: /foo bar/venv/lib/pythonABC/site-packages
Files:
  my_project-0.1.0+abc123de.dist-info/INSTALLER
  my_project-0.1.0+abc123de.dist-info/METADATA
  my_project-0.1.0+abc123de.dist-info/RECORD
  my_project-0.1.0+abc123de.dist-info/REQUESTED
  my_project-0.1.0+abc123de.dist-info/WHEEL
  my_project-0.1.0+abc123de.dist-info/entry_points.txt
  my_project.pth
";

        assert_eq!(
            parse_direct_url_path(example_without_direct_url).unwrap(),
            None
        );
    }

    #[test]
    #[cfg(target_os = "windows")]
    fn test_parse_direct_url_windows() {
        let example_with_direct_url_windows = "\
Name: my-project\r
Version: 0.1.0\r
Location: C:\\foo bar\\venv\\Lib\\site-packages\r
Files:\r
  my_project-0.1.0+abc123de.dist-info\\INSTALLER\r
  my_project-0.1.0+abc123de.dist-info\\METADATA\r
  my_project-0.1.0+abc123de.dist-info\\RECORD\r
  my_project-0.1.0+abc123de.dist-info\\REQUESTED\r
  my_project-0.1.0+abc123de.dist-info\\WHEEL\r
  my_project-0.1.0+abc123de.dist-info\\direct_url.json\r
  my_project-0.1.0+abc123de.dist-info\\entry_points.txt\r
  my_project.pth\r
";

        let expected_path = PathBuf::from(
            "C:\\foo bar\\venv\\Lib\\site-packages\\my_project-0.1.0+abc123de.dist-info\\direct_url.json",
        );
        assert_eq!(
            parse_direct_url_path(example_with_direct_url_windows).unwrap(),
            Some(expected_path)
        );
    }
}
