use const_format::formatcp;
use serde::Deserialize;
use sha1::{Digest, Sha1};
use std::{
    env, error,
    fs::{self, File},
    io::{self, Write},
    ops::Not,
    os::{fd::AsRawFd, unix::process::CommandExt},
    path::{Path, PathBuf},
    process::{self},
    thread,
    time::Duration,
};

use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Minecraft server exited with not success status: {0})")]
    ServerFailure(process::ExitStatus),
    #[error("Minecraft server jar failed hash verification {expected:?}, found {found:?}))")]
    InvalidHash { expected: Vec<u8>, found: Vec<u8> },
    #[error("Minecraft version {0} is missing from the versions manifest)")]
    MissingVersion(String),
    #[error("Minecraft Bedrock zip is malformed")]
    Malformed,
}

#[derive(Deserialize)]
struct Manifest {
    versions: Vec<Version>,
}
#[derive(Deserialize)]
struct Version {
    id: String,
    url: String,
}
#[derive(Deserialize)]
struct VersionManifest {
    downloads: Downloads,
}
#[derive(Deserialize)]
struct Downloads {
    server: Server,
}
#[derive(Deserialize)]
struct Server {
    sha1: String,
    url: String,
}

pub struct BedrockConfig<'a> {
    args: &'a [String],
    additional_args_filepath: PathBuf,
    bedrock_executable_filepath: PathBuf,
    current_dir: PathBuf,
}
impl BedrockConfig<'_> {
    pub fn build<'a>(
        args: &'a [String],
        xdg_dirs: &'a xdg::BaseDirectories,
        current_dir_environment_variable_value: &'a Result<String, env::VarError>,
    ) -> Result<BedrockConfig<'a>, Box<dyn error::Error>> {
        let mut bedrock_executable_filepath = PathBuf::new();
        bedrock_executable_filepath.push(xdg_dirs.get_data_home());
        bedrock_executable_filepath.push(CONSTANTS.bedrock_executable_filename);

        let current_dir = current_dir_environment_variable_value.as_ref().map_or_else(
            |_| {
                let mut current_dir = PathBuf::new();
                current_dir.push(xdg_dirs.get_config_home());
                current_dir.push(CONSTANTS.minecraft_config_subdirectory);
                current_dir
            },
            PathBuf::from,
        );

        let mut additional_args_filepath = PathBuf::new();
        additional_args_filepath.push(xdg_dirs.get_config_home());
        additional_args_filepath.push(CONSTANTS.additional_args_filename);

        Ok(BedrockConfig {
            args,
            additional_args_filepath,
            bedrock_executable_filepath,
            current_dir,
        })
    }
}

pub struct JavaConfig<'a> {
    jvm_settings_filepath: PathBuf,
    additional_args_filepath: PathBuf,
    args: &'a [String],
    server_jar_filepath: PathBuf,
    current_dir: PathBuf,
    java_filepath: PathBuf,
}
impl JavaConfig<'_> {
    pub fn build<'a>(
        args: &'a [String],
        xdg_dirs: &'a xdg::BaseDirectories,
        jvm_settings_filepath_environment_variable_value: &'a Result<String, env::VarError>,
        current_dir_environment_variable_value: &'a Result<String, env::VarError>,
        java_home_environment_variable_value: &Option<String>,
    ) -> Result<JavaConfig<'a>, Box<dyn error::Error>> {
        let jvm_settings_filepath = jvm_settings_filepath_environment_variable_value
            .as_ref()
            .map(PathBuf::from)
            .or_else(|err| match err {
                env::VarError::NotPresent => {
                    let mut java_settings_path = PathBuf::new();
                    java_settings_path.push(xdg_dirs.get_config_home());
                    java_settings_path.push(CONSTANTS.jvm_settings_default_filename);
                    Ok(java_settings_path)
                }
                _ => Err(err.clone()), // TODO: there is probably a better way to deal with this that will result in the clone going away
            })?;

        let mut additional_args_filepath = PathBuf::new();
        additional_args_filepath.push(xdg_dirs.get_config_home());
        additional_args_filepath.push(CONSTANTS.additional_args_filename);

        let mut server_jar_filepath = PathBuf::new();
        server_jar_filepath.push(xdg_dirs.get_data_home());
        server_jar_filepath.push(CONSTANTS.server_jar_filename);

        let current_dir = current_dir_environment_variable_value.as_ref().map_or_else(
            |_| {
                let mut current_dir = PathBuf::new();
                current_dir.push(xdg_dirs.get_config_home());
                current_dir.push(CONSTANTS.minecraft_config_subdirectory);
                current_dir
            },
            PathBuf::from,
        );

        let java_filepath = match java_home_environment_variable_value {
            None => PathBuf::from(CONSTANTS.java_binary_filename),
            Some(java_home) => {
                let mut java_filepath = PathBuf::new();
                java_filepath.push(java_home);
                java_filepath.push(CONSTANTS.java_binaries_relative_path);
                java_filepath.push(CONSTANTS.java_binary_filename);
                java_filepath
            }
        };

        Ok(JavaConfig {
            jvm_settings_filepath,
            additional_args_filepath,
            args,
            server_jar_filepath,
            current_dir,
            java_filepath,
        })
    }
}

pub struct Constants<'a> {
    pub java_server_xdg_subdirectory: &'a str,
    pub bedrock_server_xdg_subdirectory: &'a str,
    pub minecraft_config_subdirectory: &'a str,
    pub jvm_settings_filepath_environment_variable: &'a str,
    pub current_dir_environment_variable: &'a str,
    jvm_settings_default_filename: &'a str,
    additional_args_filename: &'a str,
    server_jar_filename: &'a str,
    bedrock_executable_filename: &'a str,
    bedrock_zip_filename: &'a str,
    manifest_url: &'a str,
    java_binaries_relative_path: &'a str,
    java_binary_filename: &'a str,
    mio_events_capacity: usize,
    bedrock_zip_url: &'a str,
    server_stop_command: &'a str,
}

const MINECRAFT_JAVA_VERSION: &str = include_str!("java-version");
const MINECRAFT_BEDROCK_VERSION: &str = include_str!("bedrock-version");
pub const CONSTANTS: Constants = Constants {
    java_server_xdg_subdirectory: "net.minecraft.JavaServer",
    bedrock_server_xdg_subdirectory: "net.minecraft.BedrockServer",
    minecraft_config_subdirectory: "minecraft",
    current_dir_environment_variable: "CURRENT_DIR",
    jvm_settings_filepath_environment_variable: "JVM_SETTINGS_FILEPATH",
    jvm_settings_default_filename: "jvm-settings.txt",
    additional_args_filename: "additional-args.txt",
    server_jar_filename: formatcp!("minecraft_server.{MINECRAFT_JAVA_VERSION}.jar"),
    bedrock_executable_filename: formatcp!("bedrock-server-{MINECRAFT_BEDROCK_VERSION}"),
    bedrock_zip_filename: "bedrock_server",
    manifest_url: "https://launchermeta.mojang.com/mc/game/version_manifest.json",
    bedrock_zip_url: formatcp!(
        "https://www.minecraft.net/bedrockdedicatedserver/bin-linux/bedrock-server-{MINECRAFT_BEDROCK_VERSION}.zip"
    ),
    server_stop_command: "stop\n",
    java_binaries_relative_path: "bin",
    java_binary_filename: "java",
    mio_events_capacity: 1024,
};

pub fn run_java_server(config: JavaConfig) -> Result<(), Box<dyn error::Error>> {
    let JavaConfig {
        jvm_settings_filepath,
        additional_args_filepath,
        args,
        server_jar_filepath,
        current_dir,
        java_filepath,
    } = config;

    if server_jar_filepath
        .parent()
        .expect("there should always be a parent for this path")
        .exists()
        .not()
    {
        fs::create_dir_all(
            server_jar_filepath
                .parent()
                .expect("there should always be a parent for this path"),
        )?;
    }

    verify_server_jar(&server_jar_filepath)?;

    if current_dir.exists().not() {
        fs::create_dir_all(&current_dir)?;
    }

    let jvm_settings = match jvm_settings_filepath.exists() {
        true => Some(fs::read_to_string(jvm_settings_filepath)?),
        false => None,
    };

    let additional_args = match additional_args_filepath.exists() {
        true => Some(fs::read_to_string(additional_args_filepath)?),
        false => None,
    };

    let mut process = process::Command::new(java_filepath)
        .args(
            jvm_settings
                .as_ref()
                .map(|settings| settings.split_whitespace().collect::<Vec<&str>>())
                .unwrap_or_default(),
        )
        .args(["-jar", &server_jar_filepath.to_string_lossy()])
        .args(args)
        .args(
            additional_args
                .as_ref()
                .map(|settings| settings.split_whitespace().collect::<Vec<&str>>())
                .unwrap_or_default(),
        )
        .current_dir(current_dir)
        .process_group(0) // the group id of the session is the process id of the session host. but we want the process group id to be different so that we can shield ctrl+c. 0 will make the Java process group id the same as it's process id. perfect eactly what we want :). it's going to be unique and great and different from the rust wrapper group id
        .stdin(process::Stdio::piped())
        .spawn()?;

    process
        .manage_self(
            CONSTANTS.server_stop_command,
            "Stopping Minecraft Java server",
            &[signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT],
        )
        .or_else(|err| {
            /*
             *  if there was an error while running the server then:
             *      1. try to stop the server
             *      2. regardless of whether that succeeds return the original error
             */
            eprintln!("Error while running Minecraft Java server");
            eprintln!("Attempting to shutdown Minecraft Java server");
            let _ = process.stop(CONSTANTS.server_stop_command);

            let sleep_duration = Duration::from_secs(30);
            eprintln!("Waiting {} seconds", sleep_duration.as_secs());
            thread::sleep(sleep_duration);
            match process.try_wait()? {
                None => {
                    eprintln!(
                        "Minecraft Java server stuck running with PID: {}",
                        process.id()
                    );
                }
                Some(exit_status) => {
                    eprintln!("Minecraft Java server exited with {}", exit_status);
                }
            }
            Err(err)
        })?;

    let exit_status = process.wait()?;

    if !exit_status.success() {
        return Err(Box::new(Error::ServerFailure(exit_status)));
    }

    println!("Minecraft Java server exited with {exit_status}");

    Ok(())
}

pub fn run_bedrock_server(config: BedrockConfig) -> Result<(), Box<dyn error::Error>> {
    let BedrockConfig {
        args,
        additional_args_filepath,
        bedrock_executable_filepath,
        current_dir,
    } = config;

    if bedrock_executable_filepath
        .parent()
        .expect("there should always be a parent for this path")
        .exists()
        .not()
    {
        fs::create_dir_all(
            bedrock_executable_filepath
                .parent()
                .expect("there should always be a parent for this path"),
        )?;
    }

    verify_bedrock_executable(&bedrock_executable_filepath, &current_dir)?;

    let additional_args = match additional_args_filepath.exists() {
        true => Some(fs::read_to_string(additional_args_filepath)?),
        false => None,
    };

    let mut process = process::Command::new(bedrock_executable_filepath)
        .args(args)
        .args(
            additional_args
                .as_ref()
                .map(|settings| settings.split_whitespace().collect::<Vec<&str>>())
                .unwrap_or_default(),
        )
        .current_dir(current_dir)
        .process_group(0) // the group id of the session is the process id of the session host. but we want the process group id to be different so that we can shield ctrl-c. 0 will make the Bedrock process group id the same as it's process id. perfect eactly what we want :). it's going to be unique and great and different from the rust wrapper group id
        .stdin(process::Stdio::piped())
        .spawn()?;

    process
        .manage_self(
            CONSTANTS.server_stop_command,
            "Stopping Minecraft Bedrock server",
            &[signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT],
        )
        .or_else(|err| {
            /*
             *  if there was an error while running the server then:
             *      1. try to stop the server
             *      2. regardless of whether that succeeds return the original error
             */
            eprintln!("Error while running Minecraft Bedrock server");
            eprintln!("Attempting to shutdown Minecraft Bedrock server");
            let _ = process.stop(CONSTANTS.server_stop_command);

            let sleep_duration = Duration::from_secs(30);
            eprintln!("Waiting {} seconds", sleep_duration.as_secs());
            thread::sleep(sleep_duration);
            match process.try_wait()? {
                None => {
                    eprintln!(
                        "Minecraft Bedrock server stuck running with PID: {}",
                        process.id()
                    );
                }
                Some(exit_status) => {
                    eprintln!("Minecraft Bedrock server exited with {}", exit_status);
                }
            }
            Err(err)
        })?;

    let exit_status = process.wait()?;

    if !exit_status.success() {
        return Err(Box::new(Error::ServerFailure(exit_status)));
    }

    println!("Minecraft Bedrock server exited with {exit_status}");

    Ok(())
}

trait SelfManage {
    fn manage_self(
        &mut self,
        stop_command: &str,
        shutdown_message: &str,
        signals: &[libc::c_int],
    ) -> Result<(), io::Error>;

    fn stop(&mut self, signal_stdin_message: &str) -> Result<(), io::Error>;
}

impl SelfManage for process::Child {
    fn stop(&mut self, child_stop_command: &str) -> Result<(), io::Error> {
        let mut server_stdin = self.stdin.as_ref().expect("stdin should be piped");
        server_stdin.write_all(child_stop_command.as_bytes())?;
        server_stdin.flush()
    }

    fn manage_self(
        &mut self,
        child_stop_command: &str,
        shutdown_message: &str,
        signals1: &[libc::c_int],
    ) -> Result<(), io::Error> {
        let parent_stdin = io::stdin();

        let mut poll = mio::Poll::new()?;

        let mut events = mio::Events::with_capacity(CONSTANTS.mio_events_capacity);

        let mut signals = signal_hook_mio::v1_0::Signals::new(
            [signals1, &[signal_hook::consts::SIGCHLD]].concat(),
        )?;

        const STDIN_TOKEN: mio::Token = mio::Token(0);
        const SIGNAL_TOKEN: mio::Token = mio::Token(1);

        poll.registry().register(
            &mut mio::unix::SourceFd(&parent_stdin.as_raw_fd()),
            STDIN_TOKEN,
            mio::Interest::READABLE,
        )?;

        poll.registry()
            .register(&mut signals, SIGNAL_TOKEN, mio::Interest::READABLE)?;

        let mut line = String::new();

        'outer: loop {
            poll.poll(&mut events, None).or_else(|e| {
                match e.kind() {
                    io::ErrorKind::Interrupted => {
                        // We get interrupt when a signal happens inside poll. That's non-fatal, just
                        // retry.
                        events.clear();
                        Ok(())
                    }
                    _ => Err(e),
                }
            })?;
            for event in &events {
                match event.token() {
                    SIGNAL_TOKEN => {
                        for signal in signals.pending() {
                            match signal {
                                signal_hook::consts::SIGCHLD => {
                                    break 'outer;
                                }
                                _ => {
                                    // would be great if this could be like some const generic or something
                                    if signals1.contains(&signal) {
                                        println!(
                                            "Received signal: {}",
                                            match signal {
                                                signal_hook::consts::SIGTERM => "SIGTERM",
                                                signal_hook::consts::SIGINT => "SIGINT",
                                                _ => unreachable!(
                                                    "Register other signals and match for them here"
                                                ),
                                            }
                                        );
                                        println!("{shutdown_message}");
                                        self.stop(child_stop_command)?;
                                        continue;
                                    }
                                    unreachable!("Register other signals and match for them here");
                                }
                            }
                        }
                    }
                    // TODO: there is likely a race condition here where we can try to write to stdin of the child after it has exited
                    // we should handle that error case instead of crashing
                    STDIN_TOKEN => {
                        parent_stdin.read_line(&mut line)?;
                        self.stdin
                            .as_ref()
                            .expect("stdin should be piped")
                            .write_all(line.as_bytes())?;
                        line.clear();
                    }
                    _ => unreachable!("Register other sources and match for their tokens here"),
                }
            }
        }
        Ok(())
    }
}

fn verify_server_jar(server_jar_filepath: &PathBuf) -> Result<(), Box<dyn error::Error>> {
    if server_jar_filepath.exists() {
        return Ok(());
    }

    // clear out the data directory before downloading the Minecraft server jar file
    fs::remove_dir_all(
        server_jar_filepath
            .parent()
            .expect("there should always be a parent for this path"),
    )?;
    fs::create_dir(
        server_jar_filepath
            .parent()
            .expect("there should always be a parent for this path"),
    )?;

    let manifest: Manifest = reqwest::blocking::get(CONSTANTS.manifest_url)?.json()?;
    let target_version_manifest_url = &manifest
        .versions
        .into_iter()
        .find(|version| version.id == MINECRAFT_JAVA_VERSION)
        .ok_or(Error::MissingVersion(MINECRAFT_JAVA_VERSION.to_string()))?
        .url;

    let target_version_manifest: VersionManifest =
        reqwest::blocking::get(target_version_manifest_url)?.json()?;

    let Server {
        sha1,
        url: download_url,
    } = target_version_manifest.downloads.server;

    let server_jar_bytes = reqwest::blocking::get(download_url)?.bytes()?;

    // verify download
    let mut hasher = Sha1::new();
    hasher.update(&server_jar_bytes);
    let server_jar_hash = hasher.finalize();

    let expected_hash = hex::decode(sha1)?;

    if server_jar_hash[..] != expected_hash {
        let mut server_jar_hash_vector = vec![];
        server_jar_hash_vector.extend_from_slice(&server_jar_hash[..]);
        return Err(Box::new(Error::InvalidHash {
            expected: expected_hash,
            found: server_jar_hash_vector,
        }));
    }

    let mut server_jar_file = File::create(server_jar_filepath)?;

    save_jar_file(&mut server_jar_file, server_jar_bytes).map_err(|err| {
        drop(server_jar_file);
        let res = fs::remove_file(server_jar_filepath).inspect_err(|_err| {
            eprintln!(
                "Failed to cleanup corrupted file: {}",
                server_jar_filepath.display()
            );
            eprintln!("Delete before running again");
        });
        match res {
            Err(err) => err,
            Ok(_) => err,
        }
    })?;
    Ok(())
}

fn save_jar_file(
    server_jar_file: &mut File,
    server_jar_bytes: bytes::Bytes,
) -> Result<(), io::Error> {
    server_jar_file.write_all(&server_jar_bytes)?;
    server_jar_file.flush()
}

fn verify_bedrock_executable(
    bedrock_executable_filepath: &Path,
    current_dir: &Path,
) -> Result<(), Box<dyn error::Error>> {
    if bedrock_executable_filepath.exists() {
        return Ok(());
    }

    // clear out the data directory before downloading the Minecraft server zip file
    fs::remove_dir_all(
        bedrock_executable_filepath
            .parent()
            .expect("there should always be a parent for this path"),
    )?;
    fs::create_dir(
        bedrock_executable_filepath
            .parent()
            .expect("there should always be a parent for this path"),
    )?;

    // the bedrock download has a propensity to fail. give it at least one retry
    let bedrock_zip_bytes = reqwest::blocking::get(CONSTANTS.bedrock_zip_url)
        .or_else(|_err| reqwest::blocking::get(CONSTANTS.bedrock_zip_url))?
        .bytes()?;
        
    let mut bedrock_executable_file = fs::File::create(bedrock_executable_filepath)?;
   
    save_bedrock_executable(
        &bedrock_zip_bytes,
        bedrock_executable_filepath,
        &mut bedrock_executable_file,
    )
    .map_err(|err| {
        drop(bedrock_executable_file);
        let res = fs::remove_file(bedrock_executable_filepath).inspect_err(|_err| {
            eprintln!(
                "Failed to cleanup corrupted file: {}",
                bedrock_executable_filepath.display()
            );
            eprintln!("Delete before running again");
        });
        match res {
            Err(err) => err,
            Ok(_) => err,
        }
    })?;

    if current_dir.exists().not() {
        fs::create_dir_all(current_dir)?;

        extract_bedrock_zip(bedrock_zip_bytes, current_dir).map_err(|err| {
            let res = fs::remove_file(bedrock_executable_filepath).inspect_err(|_err| {
                eprintln!(
                    "Failed to cleanup corrupted directory: {}",
                    bedrock_executable_filepath.display()
                );
                eprintln!("Delete before running again");
            });
            match res {
                Err(err) => Box::new(err),
                Ok(_) => err,
            }
        })?;
    }

    Ok(())
}

fn save_bedrock_executable(
    bedrock_zip_bytes: &bytes::Bytes,
    bedrock_executable_filepath: &Path,
    bedrock_executable_file: &mut File,
) -> Result<(), io::Error> {
    let mut archive = zip::ZipArchive::new(io::Cursor::new(&bedrock_zip_bytes))?;

    let mut bedrock_executable_bytes = archive.by_name(CONSTANTS.bedrock_zip_filename)?;

    io::copy(&mut bedrock_executable_bytes, bedrock_executable_file)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        if let Some(mode) = bedrock_executable_bytes.unix_mode() {
            fs::set_permissions(
                bedrock_executable_filepath,
                fs::Permissions::from_mode(mode),
            )?;
        }
        Ok(())
    }
}

fn extract_bedrock_zip(
    minecraft_bedrock_server_zip_bytes: bytes::Bytes,
    current_dir: &Path,
) -> Result<(), Box<dyn error::Error>> {
    let mut archive = zip::ZipArchive::new(io::Cursor::new(&minecraft_bedrock_server_zip_bytes))?;

    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let contained_path = file.enclosed_name().ok_or(Error::Malformed)?;

        let outpath = current_dir.join(contained_path);

        if file.is_dir() {
            fs::create_dir_all(&outpath)?;
        } else {
            if let Some(parent_path) = outpath.parent() {
                if !parent_path.exists() {
                    fs::create_dir_all(parent_path)?;
                }
            }
            let mut outfile = fs::File::create(&outpath)?;
            io::copy(&mut file, &mut outfile)?;
        }

        // Get and Set permissions
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;

            if let Some(mode) = file.unix_mode() {
                fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))?;
            }
        }
    }

    fs::remove_file(current_dir.join(CONSTANTS.bedrock_zip_filename))?;
    Ok(())
}
