//! Quickly up a Rust PyO3 project with setuptools-rust.
#![warn(clippy::all)]

use serde::{Deserialize, Serialize};
use std::{
    collections::HashMap,
    fs,
    io::{self, Write},
    path::{Path, PathBuf},
    process::Command,
};
use toml::Value;

mod cli;

/// [De]Serializable parts of the Cargo.toml that are modified

#[derive(Debug, Serialize, Deserialize)]
struct CargoToml {
    package: HashMap<String, Value>,
    #[serde(default = "HashMap::new")]
    dependencies: HashMap<String, Value>,
    #[serde(default = "HashMap::new")]
    lib: HashMap<String, Value>,
}

impl CargoToml {
    /// Add dependencies to the file
    pub fn add_dependencies(mut self, dependencies: &HashMap<String, String>) -> Self {
        for (dependency, version) in dependencies {
            self.dependencies
                .insert(dependency.into(), Value::String(version.into()));
        }

        self
    }

    /// Add library config
    pub fn add_library_config(mut self, name: &str) -> Self {
        self.lib.insert("name".into(), Value::String(name.into()));
        self.lib.insert(
            "crate-type".into(),
            Value::Array(vec![Value::String("cdylib".into())]),
        );

        self
    }

    /// Add pacakge information
    pub fn add_package_info(
        mut self,
        author_name: &str,
        author_email: &str,
        version: &str,
    ) -> Self {
        self.package.insert(
            "authors".into(),
            Value::Array(vec![Value::String(format!(
                "{} <{}>",
                author_name, author_email
            ))]),
        );
        self.package.insert("version".into(), version.into());

        self
    }
}

/// Run a command and get its output
fn run_and_get_output(prog: &str, args: &Vec<&str>) -> Result<String, std::io::Error> {
    let output = Command::new(prog).args(args).output()?.stdout;

    Ok(String::from_utf8_lossy(&output).into())
}

/// Prompt the user for a value
///
/// # Args
///
/// * `value` - If provided, returns this value without getting user input
/// * `default` - If provided, the default value to use if the user doesn't provide one
/// * `prompt` - The prompt to show the user (eg. `"Project name"`)
/// * `non_interactive` - Immediately returns either the value or the default. Panics if neither are available
fn get_value(
    value: Option<&str>,
    default: Option<&str>,
    prompt: &str,
    non_interactive: bool,
) -> std::io::Result<String> {
    if let Some(v) = value {
        return Ok(v.into());
    }

    if non_interactive {
        if let Some(v) = default {
            return Ok(v.into());
        } else {
            panic!("no value or default available");
        }
    }

    let mut input = String::new();

    loop {
        if let Some(v) = default {
            print!("{} [{}]: ", prompt, v);
        } else {
            print!("{}", prompt);
        }

        io::stdout().flush()?;

        io::stdin().read_line(&mut input)?;
        input = input.trim_end().into();

        if input.is_empty() {
            if let Some(v) = default {
                return Ok(v.into());
            } else {
                continue;
            }
        } else {
            return Ok(input);
        }
    }
}

fn main() -> Result<(), std::io::Error> {
    let app = cli::get_app().get_matches();

    /* Get values from CLI */

    // The path for the project
    let path = Path::new(app.value_of("path").unwrap());

    // Exit if path exists
    if fs::metadata(path).is_ok() {
        println!("Target path already exists, exiting...");
        std::process::exit(1);
    }

    let non_interactive = app.is_present("non_interactive");

    // The name of the project
    // Inferred from the path unless --name option is provided
    let project_name = get_value(
        app.value_of("name"),
        path.file_name()
            .expect("could not get project name from path")
            .to_str(),
        "Project name",
        non_interactive,
    )?
    .replace('-', "_")
    .replace(' ', "_");

    // The description of the project
    let description = get_value(
        app.value_of("description"),
        Some("Generated with pyo3-setup"),
        "Project description",
        non_interactive,
    )?;

    // The version of the project
    let project_version = get_value(
        app.value_of("version"),
        Some("0.1.0"),
        "Project version",
        non_interactive,
    )?;

    // The author of the project
    let author = get_value(
        app.value_of("author"),
        Some(run_and_get_output("git", &vec!["config", "user.name"])?.trim()),
        "Author name",
        non_interactive,
    )?;

    // The author's email
    let email = get_value(
        app.value_of("email"),
        Some(run_and_get_output("git", &vec!["config", "user.email"])?.trim()),
        "Author email",
        non_interactive,
    )?;

    // The PyO3 version
    let pyo3_version = if let Some(version) = app.value_of("pyo3_version") {
        version.to_string()
    } else {
        use cargo_edit::get_latest_dependency;
        get_latest_dependency("pyo3", false, path, &None)
            .expect("could not query for latest version of PyO3")
            .version()
            .unwrap()
            .into()
    };

    let setuptools_version = app.value_of("setuptools_version").unwrap();
    let setuptools_rust_version = app.value_of("setuptools_rust_version").unwrap();

    let mut dependencies = HashMap::<String, String>::new();
    dependencies.insert("pyo3".into(), pyo3_version.clone());

    /* Create project */
    println!("Creating project directory...");
    fs::create_dir_all(path)?;

    println!("Initializing with Cargo...");
    Command::new("cargo")
        .arg("init")
        .arg("--name")
        .arg(&project_name)
        .arg("--lib")
        .arg(&format!("{}", path.display()))
        .spawn()?
        .wait()?;

    println!("Updating Cargo configuration...");
    println!("Cargo dependencies:");
    println!("PyO3:               {}", &pyo3_version);

    // Filepaths
    let cargo_toml_path = PathBuf::from(format!("{}/Cargo.toml", path.display()));
    let requirements_txt_path = PathBuf::from(format!("{}/requirements.txt", path.display()));
    let manifest_in_path = PathBuf::from(format!("{}/MANIFEST.in", path.display()));
    let pyproject_path = PathBuf::from(format!("{}/pyproject.toml", path.display()));
    let setup_py_path = PathBuf::from(format!("{}/setup.py", path.display()));
    let py_module_path = PathBuf::from(format!("{}/{}", path.display(), project_name));
    let readme_path = PathBuf::from(format!("{}/README.md", path.display()));
    let lib_rs_path = PathBuf::from(format!("{}/src/lib.rs", path.display()));
    let gitignore_path = PathBuf::from(format!("{}/.gitignore", path.display()));

    // Read Cargo.toml file and update dependencies
    {
        let cargo_toml_raw = fs::read_to_string(&cargo_toml_path)?;
        let cargo_toml = {
            toml::from_str::<CargoToml>(&cargo_toml_raw)
                .expect("could not parse Cargo.toml")
                .add_dependencies(&dependencies)
                .add_library_config(&project_name)
                .add_package_info(&author, &email, &project_version)
        };

        fs::write(&cargo_toml_path, toml::to_string(&cargo_toml).unwrap())?;
        println!("Wrote updated Cargo.toml");
    }

    // Update default src/lib.rs
    {
        println!("Updating lib.rs...");

        let lib_rs = format!(
            r#"//! {description}
use pyo3::prelude::*;

#[pymodule]
fn {name}(_py: Python, m: &PyModule) -> PyResult<()> {{
    // Add stuff to your module here
    // Info available in the PyO3 docs
    Ok(())
}}
"#,
            name = project_name,
            description = description,
        );

        fs::write(&lib_rs_path, &lib_rs)?;
    }

    // Add default Python package
    {
        println!("Creating default Python package...");
        fs::create_dir_all(&py_module_path)?;

        let init_py = format!(
            concat!(r#""""Main {name} module""""#, "\nfrom .{name} import *\n"),
            name = project_name
        );

        fs::write(
            &format!("{}/__init__.py", py_module_path.display()),
            &init_py,
        )?;
    }

    // Add Python requirements files
    {
        println!("Creating requirements.txt (for building from source)...");
        println!("Python dependencies:");
        println!("setuptools:         {}", &setuptools_version);
        println!("setuptools-rust:    {}", &setuptools_rust_version);

        let requirements = format!(
            "# These requirements are for building the package from source\n\
            setuptools{}\n\
            setuptools-rust{}\n",
            setuptools_version, setuptools_rust_version
        );

        fs::write(&requirements_txt_path, &requirements)?;
    }

    // Add manifest to include Cargo files
    {
        println!("Creating MAINFEST.in...");
        let manifest = "include Cargo.toml\n\
                            recursive-include src *\n";
        fs::write(&manifest_in_path, &manifest)?;
    }

    // Add pyproject.toml for build system
    {
        println!("Creating pyproject.toml...");
        let pyproject = r#"[build-system]
requires = ["setuptools", "wheel", "setuptools-rust"]
"#;

        fs::write(&pyproject_path, &pyproject)?;
    }

    // Add default setup.py to get things working
    {
        println!("Creating setup.py...");
        let setup_py = format!(
            r#"from setuptools import setup
from setuptools_rust import Binding, RustExtension

with open("README.md", "r") as f:
    long_description = f.read()

setup(
    name="{name}",
    version="{version}",
    author="{author}",
    author_email="{author_email}",
    description="{description}",
    long_description=long_description,
    long_description_content_type="text/markdown",
    # license="MIT OR Apache-2.0",
    # license_files=("LICENSE-MIT", "LICENSE-APACHE"),
    rust_extensions=[RustExtension("{name}.{name}", binding=Binding.PyO3)],
    packages=["{name}"],
    classifiers=[
        "Intended Audience :: Developers",
        # "License :: OSI Approved :: Apache Software License",
        # "License :: OSI Approved :: MIT License",
        "Natural Language :: English",
        "Operating System :: OS Independent",
        "Programming Language :: Rust",
        "Programming Language :: Python",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.6",
        "Programming Language :: Python :: 3.7",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: 3.10",
    ],
    python_requires="~=3.6",
    zip_safe=False,
)
"#,
            name = project_name,
            description = description,
            version = project_version,
            author = author,
            author_email = email,
        );

        fs::write(&setup_py_path, &setup_py)?;
    }

    // Add README.md
    {
        println!("Creating README.md...");
        let readme = format!(
            r#"# {name}

{description}

## Installation

To install, run:

```sh
$ pip install {name}
```

## Building from source

[Rust](https://www.rust-lang.org/learn/get-started) is required to build this project from source.
Python dependencies can be installed with `pip install -r requirements.txt`.

To build for development:

```sh
$ python setup.py develop
```

To build for publishing to PyPI:

```sh
$ python setup.py sdist bdist_wheel
```

To install for use in other projects:

```sh
$ python setup.py install
```
"#,
            name = project_name,
            description = description,
        );

        fs::write(&readme_path, &readme)?;
    }

    // Add .gitignore
    {
        println!("Creating .gitignore...");
        let gitignore = r#"# python files
*.py[c,d]
/*.egg-info/
/build/
/dist/

# rust files
/target/
Cargo.lock
"#;

        fs::write(&gitignore_path, &gitignore)?;
    }

    println!("Done!");
    println!("Happy hacking :)");
    Ok(())
}
