use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::{env as std_env, fs};

use boml::Toml;
use boml::types::TomlValue;

use crate::utils::{
    create_dir, create_symlink, get_os_name, get_sysroot_dir, run_command_with_output,
    rustc_version_info, split_args,
};

#[derive(Default, PartialEq, Eq, Clone, Copy, Debug)]
pub enum Channel {
    #[default]
    Debug,
    Release,
}

impl Channel {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Debug => "debug",
            Self::Release => "release",
        }
    }
}

fn failed_config_parsing(config_file: &Path, err: &str) -> Result<ConfigFile, String> {
    Err(format!("Failed to parse `{}`: {}", config_file.display(), err))
}

#[derive(Default)]
pub struct ConfigFile {
    gcc_path: Option<String>,
    download_gccjit: Option<bool>,
}

impl ConfigFile {
    pub fn new(config_file: &Path) -> Result<Self, String> {
        let content = fs::read_to_string(config_file).map_err(|_| {
            format!(
                "Failed to read `{}`. Take a look at `Readme.md` to see how to set up the project",
                config_file.display(),
            )
        })?;
        let toml = Toml::parse(&content).map_err(|err| {
            format!("Error occurred around `{}`: {:?}", &content[err.start..=err.end], err.kind)
        })?;
        let mut config = Self::default();
        for (key, value) in toml.iter() {
            match (key, value) {
                ("gcc-path", TomlValue::String(value)) => {
                    config.gcc_path = Some(value.as_str().to_string())
                }
                ("gcc-path", _) => {
                    return failed_config_parsing(config_file, "Expected a string for `gcc-path`");
                }
                ("download-gccjit", TomlValue::Boolean(value)) => {
                    config.download_gccjit = Some(*value)
                }
                ("download-gccjit", _) => {
                    return failed_config_parsing(
                        config_file,
                        "Expected a boolean for `download-gccjit`",
                    );
                }
                _ => return failed_config_parsing(config_file, &format!("Unknown key `{key}`")),
            }
        }
        match (config.gcc_path.as_mut(), config.download_gccjit) {
            (None, None | Some(false)) => {
                return failed_config_parsing(
                    config_file,
                    "At least one of `gcc-path` or `download-gccjit` value must be set",
                );
            }
            (Some(_), Some(true)) => {
                println!(
                    "WARNING: both `gcc-path` and `download-gccjit` arguments are used, \
                    ignoring `gcc-path`"
                );
            }
            (Some(gcc_path), _) => {
                let path = Path::new(gcc_path);
                *gcc_path = path
                    .canonicalize()
                    .map_err(|err| format!("Failed to get absolute path of `{gcc_path}`: {err:?}"))?
                    .display()
                    .to_string();
            }
            _ => {}
        }
        Ok(config)
    }
}

#[derive(Default, Debug, Clone)]
pub struct ConfigInfo {
    pub target: String,
    pub target_triple: String,
    pub host_triple: String,
    pub rustc_command: Vec<String>,
    pub run_in_vm: bool,
    pub cargo_target_dir: String,
    pub dylib_ext: String,
    pub sysroot_release_channel: bool,
    pub channel: Channel,
    pub sysroot_panic_abort: bool,
    pub cg_backend_path: String,
    pub sysroot_path: String,
    pub gcc_path: Option<String>,
    config_file: Option<String>,
    // This is used in particular in rust compiler bootstrap because it doesn't run at the root
    // of the `cg_gcc` folder, making it complicated for us to get access to local files we need
    // like `libgccjit.version` or `config.toml`.
    cg_gcc_path: Option<PathBuf>,
    // Needed for the `info` command which doesn't want to actually download the lib if needed,
    // just to set the `gcc_path` field to display it.
    pub no_download: bool,
    pub no_default_features: bool,
    pub backend: Option<String>,
    pub features: Vec<String>,
}

impl ConfigInfo {
    /// Returns `true` if the argument was taken into account.
    pub fn parse_argument(
        &mut self,
        arg: &str,
        args: &mut impl Iterator<Item = String>,
    ) -> Result<bool, String> {
        match arg {
            "--features" => {
                if let Some(arg) = args.next() {
                    self.features.push(arg);
                } else {
                    return Err("Expected a value after `--features`, found nothing".to_string());
                }
            }
            "--target" => {
                if let Some(arg) = args.next() {
                    self.target = arg;
                } else {
                    return Err("Expected a value after `--target`, found nothing".to_string());
                }
            }
            "--target-triple" => match args.next() {
                Some(arg) if !arg.is_empty() => self.target_triple = arg.to_string(),
                _ => {
                    return Err(
                        "Expected a value after `--target-triple`, found nothing".to_string()
                    );
                }
            },
            "--out-dir" => match args.next() {
                Some(arg) if !arg.is_empty() => {
                    self.cargo_target_dir = arg.to_string();
                }
                _ => return Err("Expected a value after `--out-dir`, found nothing".to_string()),
            },
            "--config-file" => match args.next() {
                Some(arg) if !arg.is_empty() => {
                    self.config_file = Some(arg.to_string());
                }
                _ => {
                    return Err("Expected a value after `--config-file`, found nothing".to_string());
                }
            },
            "--release-sysroot" => self.sysroot_release_channel = true,
            "--release" => self.channel = Channel::Release,
            "--sysroot-panic-abort" => self.sysroot_panic_abort = true,
            "--gcc-path" => match args.next() {
                Some(arg) if !arg.is_empty() => {
                    self.gcc_path = Some(arg);
                }
                _ => {
                    return Err("Expected a value after `--gcc-path`, found nothing".to_string());
                }
            },
            "--cg_gcc-path" => match args.next() {
                Some(arg) if !arg.is_empty() => {
                    self.cg_gcc_path = Some(arg.into());
                }
                _ => {
                    return Err("Expected a value after `--cg_gcc-path`, found nothing".to_string());
                }
            },
            "--use-backend" => match args.next() {
                Some(backend) if !backend.is_empty() => self.backend = Some(backend),
                _ => return Err("Expected an argument after `--use-backend`, found nothing".into()),
            },
            "--no-default-features" => self.no_default_features = true,
            _ => return Ok(false),
        }
        Ok(true)
    }

    pub fn rustc_command_vec(&self) -> Vec<&dyn AsRef<OsStr>> {
        let mut command: Vec<&dyn AsRef<OsStr>> = Vec::with_capacity(self.rustc_command.len());
        for arg in self.rustc_command.iter() {
            command.push(arg);
        }
        command
    }

    pub fn get_gcc_commit(&self) -> Result<String, String> {
        let commit_hash_file = self.compute_path("libgccjit.version");
        let content = fs::read_to_string(&commit_hash_file).map_err(|_| {
            format!(
                "Failed to read `{}`. Take a look at `Readme.md` to see how to set up the project",
                commit_hash_file.display(),
            )
        })?;
        let commit = content.trim();
        // This is a very simple check to ensure this is not a path. For the rest, it'll just fail
        // when trying to download the file so we should be fine.
        if commit.contains('/') || commit.contains('\\') {
            return Err(format!(
                "{}: invalid commit hash `{}`",
                commit_hash_file.display(),
                commit,
            ));
        }
        Ok(commit.to_string())
    }

    fn download_gccjit_if_needed(&mut self) -> Result<(), String> {
        let output_dir = Path::new(crate::BUILD_DIR).join("libgccjit");
        let commit = self.get_gcc_commit()?;

        let output_dir = output_dir.join(&commit);
        if !output_dir.is_dir() {
            create_dir(&output_dir)?;
        }
        let output_dir = output_dir.canonicalize().map_err(|err| {
            format!("Failed to get absolute path of `{}`: {:?}", output_dir.display(), err)
        })?;

        let libgccjit_so_name = "libgccjit.so";
        let libgccjit_so = output_dir.join(libgccjit_so_name);
        if !libgccjit_so.is_file() && !self.no_download {
            // Download time!
            let tempfile_name = format!("{libgccjit_so_name}.download");
            let tempfile = output_dir.join(&tempfile_name);
            let is_in_ci = std::env::var("GITHUB_ACTIONS").is_ok();

            download_gccjit(&commit, &output_dir, tempfile_name, !is_in_ci)?;

            let libgccjit_so = output_dir.join(libgccjit_so_name);
            // If we reach this point, it means the file was correctly downloaded, so let's
            // rename it!
            std::fs::rename(&tempfile, &libgccjit_so).map_err(|err| {
                format!(
                    "Failed to rename `{}` into `{}`: {:?}",
                    tempfile.display(),
                    libgccjit_so.display(),
                    err,
                )
            })?;

            println!("Downloaded libgccjit.so version {commit} successfully!");
            // We need to create a link named `libgccjit.so.0` because that's what the linker is
            // looking for.
            create_symlink(&libgccjit_so, output_dir.join(format!("{libgccjit_so_name}.0")))?;
        }

        let gcc_path = output_dir.display().to_string();
        println!("Using `{gcc_path}` as path for libgccjit");
        self.gcc_path = Some(gcc_path);
        Ok(())
    }

    pub fn compute_path<P: AsRef<Path>>(&self, other: P) -> PathBuf {
        match self.cg_gcc_path {
            Some(ref path) => path.join(other),
            None => PathBuf::new().join(other),
        }
    }

    pub fn setup_gcc_path(&mut self) -> Result<(), String> {
        // If the user used the `--gcc-path` option, no need to look at `config.toml` content
        // since we already have everything we need.
        if let Some(gcc_path) = &self.gcc_path {
            println!(
                "`--gcc-path` was provided, ignoring config file. Using `{gcc_path}` as path for libgccjit"
            );
            return Ok(());
        }
        let config_file = match self.config_file.as_deref() {
            Some(config_file) => config_file.into(),
            None => self.compute_path("config.toml"),
        };
        let ConfigFile { gcc_path, download_gccjit } = ConfigFile::new(&config_file)?;

        if let Some(true) = download_gccjit {
            self.download_gccjit_if_needed()?;
            return Ok(());
        }
        let Some(gcc_path) = gcc_path else {
            return Err(format!("missing `gcc-path` value from `{}`", config_file.display()));
        };
        println!(
            "GCC path retrieved from `{}`. Using `{}` as path for libgccjit",
            config_file.display(),
            gcc_path
        );
        self.gcc_path = Some(gcc_path);
        Ok(())
    }

    pub fn setup(
        &mut self,
        env: &mut HashMap<String, String>,
        use_system_gcc: bool,
    ) -> Result<(), String> {
        env.insert("CARGO_INCREMENTAL".to_string(), "0".to_string());

        let gcc_path = if !use_system_gcc {
            if self.gcc_path.is_none() {
                self.setup_gcc_path()?;
            }
            self.gcc_path.clone().expect(
                "The config module should have emitted an error if the GCC path wasn't provided",
            )
        } else {
            String::new()
        };
        env.insert("GCC_PATH".to_string(), gcc_path.clone());

        if self.cargo_target_dir.is_empty() {
            match env.get("CARGO_TARGET_DIR").filter(|dir| !dir.is_empty()) {
                Some(cargo_target_dir) => self.cargo_target_dir = cargo_target_dir.clone(),
                None => self.cargo_target_dir = "target/out".to_string(),
            }
        }

        let os_name = get_os_name()?;
        self.dylib_ext = match os_name.as_str() {
            "Linux" => "so",
            "Darwin" => "dylib",
            os => return Err(format!("unsupported OS `{os}`")),
        }
        .to_string();
        let rustc = match env.get("RUSTC") {
            Some(r) if !r.is_empty() => r.to_string(),
            _ => "rustc".to_string(),
        };
        self.host_triple = match rustc_version_info(Some(&rustc))?.host {
            Some(host) => host,
            None => return Err("no host found".to_string()),
        };

        if self.target_triple.is_empty() {
            self.target_triple = self.host_triple.clone();
        }
        if self.target.is_empty() && !self.target_triple.is_empty() {
            self.target = self.target_triple.clone();
        }

        let mut linker = None;

        if self.host_triple != self.target_triple {
            if self.target_triple.is_empty() {
                return Err("Unknown non-native platform".to_string());
            }
            linker = Some(format!("-Clinker={}-gcc", self.target_triple));
            self.run_in_vm = true;
        }

        let current_dir =
            std_env::current_dir().map_err(|error| format!("`current_dir` failed: {error:?}"))?;
        let channel = if self.channel == Channel::Release {
            "release"
        } else if let Some(channel) = env.get("CHANNEL") {
            channel.as_str()
        } else {
            "debug"
        };

        let mut rustflags = Vec::new();
        self.cg_backend_path = current_dir
            .join("target")
            .join(channel)
            .join(format!("librustc_codegen_gcc.{}", self.dylib_ext))
            .display()
            .to_string();
        self.sysroot_path =
            current_dir.join(get_sysroot_dir()).join("sysroot").display().to_string();
        if let Some(backend) = &self.backend {
            // This option is only used in the rust compiler testsuite. The sysroot is handled
            // by its build system directly so no need to set it ourselves.
            rustflags.push(format!("-Zcodegen-backend={backend}"));
        } else {
            rustflags.extend_from_slice(&[
                "--sysroot".to_string(),
                self.sysroot_path.clone(),
                format!("-Zcodegen-backend={}", self.cg_backend_path),
            ]);
        }

        // This environment variable is useful in case we want to change options of rustc commands.
        // We have a different environment variable than RUSTFLAGS to make sure those flags are
        // only sent to rustc_codegen_gcc and not the LLVM backend.
        if let Some(cg_rustflags) = env.get("CG_RUSTFLAGS") {
            rustflags.extend_from_slice(&split_args(cg_rustflags)?);
        }
        if let Some(test_flags) = env.get("TEST_FLAGS") {
            rustflags.extend_from_slice(&split_args(test_flags)?);
        }

        if let Some(linker) = linker {
            rustflags.push(linker.to_string());
        }

        if self.no_default_features {
            rustflags.push("-Csymbol-mangling-version=v0".to_string());
        }

        // FIXME(antoyo): remove once the atomic shim is gone
        if os_name == "Darwin" {
            rustflags.extend_from_slice(&[
                "-Clink-arg=-undefined".to_string(),
                "-Clink-arg=dynamic_lookup".to_string(),
            ]);
        }
        env.insert("RUSTFLAGS".to_string(), rustflags.join(" "));
        // display metadata load errors
        env.insert("RUSTC_LOG".to_string(), "warn".to_string());

        // NOTE: To avoid the -fno-inline errors, use /opt/gcc/bin/gcc instead of cc.
        // To do so, add a symlink for cc to /opt/gcc/bin/gcc in our PATH.
        // Another option would be to add the following Rust flag: -Clinker=/opt/gcc/bin/gcc
        let path = std::env::var("PATH").unwrap_or_default();
        env.insert(
            "PATH".to_string(),
            format!(
                "/opt/gcc/bin:/opt/m68k-unknown-linux-gnu/bin{}{}",
                if path.is_empty() { "" } else { ":" },
                path
            ),
        );

        self.rustc_command = vec![rustc];
        self.rustc_command.extend_from_slice(&rustflags);
        self.rustc_command.extend_from_slice(&[
            "-L".to_string(),
            format!("crate={}", self.cargo_target_dir),
            "--out-dir".to_string(),
            self.cargo_target_dir.clone(),
        ]);

        if !env.contains_key("RUSTC_LOG") {
            env.insert("RUSTC_LOG".to_string(), "warn".to_string());
        }
        Ok(())
    }

    pub fn show_usage() {
        println!(
            "    --features [arg]       : Add a new feature [arg]
    --target-triple [arg]  : Set the target triple to [arg]
    --target [arg]         : Set the target to [arg]
    --release              : Build backend in release mode with optimized dependencies
    --out-dir              : Location where the files will be generated
    --config-file          : Location of the config file to be used
    --gcc-path             : Location of the GCC root folder
    --cg_gcc-path          : Location of the rustc_codegen_gcc root folder (used
                             when ran from another directory)
    --no-default-features  : Add `--no-default-features` flag to cargo commands
    --use-backend          : Useful only for rustc testsuite"
        );
    }
}

fn download_gccjit(
    commit: &str,
    output_dir: &Path,
    tempfile_name: String,
    with_progress_bar: bool,
) -> Result<(), String> {
    let url = if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
        format!("https://github.com/rust-lang/gcc/releases/download/master-{commit}/libgccjit.so")
    } else {
        eprintln!(
            "\
Pre-compiled libgccjit.so not available for this os or architecture.
Please compile it yourself and update the `config.toml` file
to `download-gccjit = false` and set `gcc-path` to the appropriate directory."
        );
        return Err(String::from(
            "no appropriate pre-compiled libgccjit.so available for download",
        ));
    };

    println!("Downloading `{url}`...");

    // Try curl. If that fails and we are on windows, fallback to PowerShell.
    let mut ret = run_command_with_output(
        &[
            &"curl",
            &"--speed-time",
            &"30",
            &"--speed-limit",
            &"10", // timeout if speed is < 10 bytes/sec for > 30 seconds
            &"--connect-timeout",
            &"30", // timeout if cannot connect within 30 seconds
            &"-o",
            &tempfile_name,
            &"--retry",
            &"3",
            &"-SRfL",
            if with_progress_bar { &"--progress-bar" } else { &"-s" },
            &url.as_str(),
        ],
        Some(output_dir),
    );
    if ret.is_err() && cfg!(windows) {
        eprintln!("Fallback to PowerShell");
        ret = run_command_with_output(
            &[
                &"PowerShell.exe",
                &"/nologo",
                &"-Command",
                &"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;",
                &format!(
                    "(New-Object System.Net.WebClient).DownloadFile('{url}', '{tempfile_name}')",
                )
                .as_str(),
            ],
            Some(output_dir),
        );
    }
    ret
}
