use std::{
    env, error,
    ffi::c_int,
    fs,
    io::{self, Write},
    ops::Not,
    os::{fd::AsRawFd, unix::process::CommandExt},
    path::PathBuf,
    process::{self},
    thread,
    time::Duration,
};

use thiserror::Error;

mod bedrock;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Minecraft server exited with not success status: {0})")]
    ServerFailure(process::ExitStatus),
    #[error("Minecraft Bedrock zip is malformed")]
    Malformed,
    #[error("Minecraft Bedrock release metadata is invalid")]
    InvalidBedrockReleaseMetadata,
    #[error("Minecraft Bedrock download policy must include at least one attempt")]
    InvalidBedrockDownloadPolicy,
    #[error("Minecraft Bedrock archive exceeds the {0}-byte download limit")]
    BedrockArchiveTooLarge(u64),
    #[error("Minecraft Bedrock archive exceeds the {0}-byte extraction limit")]
    BedrockExtractionTooLarge(u64),
    #[error("Minecraft Bedrock archive checksum mismatch: expected {expected}, received {actual}")]
    BedrockChecksumMismatch { expected: String, actual: String },
    #[error("XDG config home is unavailable because HOME could not be found")]
    XdgConfigHomeUnavailable,
    #[error("XDG data home is unavailable because HOME could not be found")]
    XdgDataHomeUnavailable,
}

pub struct BedrockConfig<'a> {
    args: &'a [String],
    bedrock_executable_filepath: PathBuf,
    current_dir: PathBuf,
}
impl BedrockConfig<'_> {
    /// Builds the filesystem and argument configuration for a Bedrock server.
    ///
    /// # Errors
    ///
    /// Returns an error when the required XDG data or configuration directory
    /// cannot be determined.
    pub fn build<'a>(
        args: &'a [String],
        xdg_dirs: &'a xdg::BaseDirectories,
        working_directory_environment_variable_value: &'a Result<String, env::VarError>,
    ) -> Result<BedrockConfig<'a>, Box<dyn error::Error>> {
        let bedrock_executable_filepath = xdg_dirs
            .get_data_file(bedrock_executable_filename())
            .ok_or(Error::XdgDataHomeUnavailable)?;

        let current_dir = match working_directory_environment_variable_value {
            Ok(current_dir) => PathBuf::from(current_dir),
            Err(_) => xdg_dirs
                .get_config_file(CONSTANTS.minecraft_config_subdirectory)
                .ok_or(Error::XdgConfigHomeUnavailable)?,
        };

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

pub struct JavaConfig<'a> {
    server_arguments: &'a [String],
    jvm_arguments: &'a [String],
    wrapper_jar_filepath: PathBuf,
    current_dir: PathBuf,
    java_filepath: PathBuf,
    java_server_kind: JavaServerKind,
}

pub struct SupervisorInvocation {
    java_filepath: PathBuf,
    jvm_arguments: Vec<String>,
    server_arguments: Vec<String>,
}

impl SupervisorInvocation {
    /// Reads a supervisor invocation from the process arguments.
    ///
    /// # Errors
    ///
    /// Returns an error when the encoded invocation is invalid.
    pub fn from_process_arguments() -> Result<Self, Box<dyn error::Error>> {
        Self::parse(env::args().skip(1), PathBuf::from("java"))
    }

    fn parse(
        arguments: impl IntoIterator<Item = String>,
        default_java: PathBuf,
    ) -> Result<Self, Box<dyn error::Error>> {
        let mut arguments = arguments.into_iter();
        let Some(first) = arguments.next() else {
            return Ok(Self {
                java_filepath: default_java,
                jvm_arguments: Vec::new(),
                server_arguments: Vec::new(),
            });
        };

        if first != CONSTANTS.supervisor_protocol {
            return Ok(Self {
                java_filepath: default_java,
                jvm_arguments: Vec::new(),
                server_arguments: std::iter::once(first).chain(arguments).collect(),
            });
        }

        let java_filepath = arguments
            .next()
            .map(PathBuf::from)
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing Java path"))?;
        let jvm_argument_count = arguments.next().ok_or_else(|| {
            io::Error::new(io::ErrorKind::InvalidInput, "missing JVM argument count")
        })?;
        let jvm_argument_count: usize = jvm_argument_count.parse().map_err(|error| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid JVM argument count: {error}"),
            )
        })?;
        let jvm_arguments: Vec<String> = arguments.by_ref().take(jvm_argument_count).collect();
        if jvm_arguments.len() != jvm_argument_count {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "fewer JVM arguments than declared",
            )
            .into());
        }

        Ok(Self {
            java_filepath,
            jvm_arguments,
            server_arguments: arguments.collect(),
        })
    }
}

impl JavaConfig<'_> {
    fn build<'a>(
        invocation: &'a SupervisorInvocation,
        xdg_dirs: &'a xdg::BaseDirectories,
        working_directory_environment_variable_value: &'a Result<String, env::VarError>,
        wrapper_jar_filepath: &str,
        java_server_kind: JavaServerKind,
    ) -> Result<JavaConfig<'a>, Box<dyn error::Error>> {
        let current_dir = match working_directory_environment_variable_value {
            Ok(current_dir) => PathBuf::from(current_dir),
            Err(_) => xdg_dirs
                .get_config_file(CONSTANTS.minecraft_config_subdirectory)
                .ok_or(Error::XdgConfigHomeUnavailable)?,
        };

        Ok(JavaConfig {
            server_arguments: &invocation.server_arguments,
            jvm_arguments: &invocation.jvm_arguments,
            wrapper_jar_filepath: PathBuf::from(wrapper_jar_filepath),
            current_dir,
            java_filepath: invocation.java_filepath.clone(),
            java_server_kind,
        })
    }

    /// Builds the configuration for the vanilla Java server supervisor.
    ///
    /// # Errors
    ///
    /// Returns an error when the required XDG configuration directory cannot
    /// be determined.
    pub fn build_vanilla<'a>(
        invocation: &'a SupervisorInvocation,
        xdg_dirs: &'a xdg::BaseDirectories,
        working_directory_environment_variable_value: &'a Result<String, env::VarError>,
    ) -> Result<JavaConfig<'a>, Box<dyn error::Error>> {
        Self::build(
            invocation,
            xdg_dirs,
            working_directory_environment_variable_value,
            CONSTANTS.minecraft_java_wrapper_jar_filepath,
            JavaServerKind::Vanilla,
        )
    }

    /// Builds the configuration for the Fabric server supervisor.
    ///
    /// # Errors
    ///
    /// Returns an error when the required XDG configuration directory cannot
    /// be determined.
    pub fn build_fabric<'a>(
        invocation: &'a SupervisorInvocation,
        xdg_dirs: &'a xdg::BaseDirectories,
        working_directory_environment_variable_value: &'a Result<String, env::VarError>,
    ) -> Result<JavaConfig<'a>, Box<dyn error::Error>> {
        Self::build(
            invocation,
            xdg_dirs,
            working_directory_environment_variable_value,
            CONSTANTS.minecraft_fabric_wrapper_jar_filepath,
            JavaServerKind::Fabric,
        )
    }
}

#[derive(Clone, Copy)]
pub enum JavaServerKind {
    Vanilla,
    Fabric,
}

impl JavaServerKind {
    const fn display_name(self) -> &'static str {
        match self {
            Self::Vanilla => "Minecraft Java server",
            Self::Fabric => "Minecraft Fabric server",
        }
    }
}

pub struct Constants<'a> {
    pub java_server_xdg_subdirectory: &'a str,
    pub fabric_server_xdg_subdirectory: &'a str,
    pub bedrock_server_xdg_subdirectory: &'a str,
    pub minecraft_config_subdirectory: &'a str,
    pub working_directory_environment_variable: &'a str,
    pub minecraft_java_wrapper_jar_filepath: &'a str,
    pub minecraft_fabric_wrapper_jar_filepath: &'a str,
    supervisor_protocol: &'a str,
    mio_events_capacity: usize,
    server_stop_command: &'a str,
}

pub const CONSTANTS: Constants<'static> = Constants {
    java_server_xdg_subdirectory: "net.minecraft.JavaServer",
    fabric_server_xdg_subdirectory: "net.fabricmc.FabricServer",
    bedrock_server_xdg_subdirectory: "net.minecraft.BedrockServer",
    minecraft_config_subdirectory: "minecraft",
    working_directory_environment_variable: "MINECRAFT_SERVER_WORKING_DIRECTORY",
    minecraft_java_wrapper_jar_filepath: "/usr/lib/java/minecraft-java-server-wrapper.jar",
    minecraft_fabric_wrapper_jar_filepath: "/usr/lib/java/minecraft-fabric-server-wrapper.jar",
    supervisor_protocol: "minecraft-server-supervisor-v1",
    server_stop_command: "stop\n",
    mio_events_capacity: 1024,
};

fn bedrock_executable_filename() -> String {
    bedrock::executable_filename()
}

/// Runs a Java server under the signal-aware supervisor.
///
/// # Errors
///
/// Returns an error when process setup, I/O, signal handling, or the server
/// process fails.
pub fn run_java_server(config: &JavaConfig<'_>) -> Result<(), Box<dyn error::Error>> {
    if config.current_dir.exists().not() {
        fs::create_dir_all(&config.current_dir)?;
    }

    let java_server_kind = config.java_server_kind;

    let mut process = java_server_command(config)
        .current_dir(&config.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,
            &format!("Stopping {}", java_server_kind.display_name()),
            &[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 {}", java_server_kind.display_name());
            eprintln!("Attempting to shutdown {}", java_server_kind.display_name());
            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!(
                        "{} stuck running with PID: {}",
                        java_server_kind.display_name(),
                        process.id()
                    );
                }
                Some(exit_status) => {
                    eprintln!(
                        "{} exited with {}",
                        java_server_kind.display_name(),
                        exit_status
                    );
                }
            }
            Err(err)
        })?;

    let exit_status = process.wait()?;

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

    println!(
        "{} exited with {exit_status}",
        java_server_kind.display_name()
    );

    Ok(())
}

fn java_server_command(config: &JavaConfig<'_>) -> process::Command {
    // The packaged Kotlin wrapper jars own version locking, downloading,
    // checksum verification, and calling the Rust JNI exec helper. Once one
    // execs, this child is the requested server and retains its PID and stdin.
    let mut command = process::Command::new(&config.java_filepath);
    command
        .args(config.jvm_arguments)
        .arg("-jar")
        .arg(&config.wrapper_jar_filepath)
        .args(config.server_arguments);
    command
}

/// Runs a Bedrock server under the signal-aware supervisor.
///
/// # Errors
///
/// Returns an error when downloading or extracting the server, process setup,
/// I/O, signal handling, or the server process fails.
pub fn run_bedrock_server(config: BedrockConfig<'_>) -> Result<(), Box<dyn error::Error>> {
    let BedrockConfig {
        args,
        bedrock_executable_filepath,
        current_dir,
    } = config;

    let bedrock_data_directory = bedrock_executable_filepath.parent().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "Bedrock executable path must have a parent directory",
        )
    })?;
    if bedrock_data_directory.exists().not() {
        fs::create_dir_all(bedrock_data_directory)?;
    }

    bedrock::ensure_installed(&bedrock_executable_filepath, &current_dir)?;

    let mut library_paths = vec![PathBuf::from(".")];
    if let Some(existing_library_path) = env::var_os("LD_LIBRARY_PATH") {
        library_paths.extend(env::split_paths(&existing_library_path));
    }
    let library_path = env::join_paths(library_paths)?;

    let mut process = process::Command::new(bedrock_executable_filepath)
        .args(args)
        .current_dir(current_dir)
        .env("LD_LIBRARY_PATH", library_path)
        .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: &[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: &[c_int],
    ) -> Result<(), io::Error> {
        const STDIN_TOKEN: mio::Token = mio::Token(0);
        const SIGNAL_TOKEN: mio::Token = mio::Token(1);

        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(),
        )?;

        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() {
                            if 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(())
    }
}

#[cfg(test)]
mod tests {
    use super::{CONSTANTS, JavaConfig, JavaServerKind, SupervisorInvocation, java_server_command};
    use std::{ffi::OsStr, path::PathBuf};

    #[test]
    fn supervisor_protocol_separates_jvm_and_server_arguments() {
        let invocation = SupervisorInvocation::parse(
            [
                CONSTANTS.supervisor_protocol,
                "/jdk/bin/java",
                "2",
                "-Xmx8G",
                "-Dmessage=with spaces",
                "--nogui",
                "server argument",
            ]
            .map(String::from),
            PathBuf::from("java"),
        )
        .unwrap();

        assert_eq!(invocation.java_filepath, PathBuf::from("/jdk/bin/java"));
        assert_eq!(
            invocation.jvm_arguments,
            ["-Xmx8G", "-Dmessage=with spaces"]
        );
        assert_eq!(invocation.server_arguments, ["--nogui", "server argument"]);
    }

    #[test]
    fn direct_native_invocation_remains_supported() {
        let invocation = SupervisorInvocation::parse(
            ["--nogui", "server argument"].map(String::from),
            PathBuf::from("java"),
        )
        .unwrap();

        assert_eq!(invocation.java_filepath, PathBuf::from("java"));
        assert!(invocation.jvm_arguments.is_empty());
        assert_eq!(invocation.server_arguments, ["--nogui", "server argument"]);
    }

    #[test]
    fn java_command_uses_injected_configuration() {
        let jvm_arguments = ["-Xmx8G".to_string(), "-Dmessage=with spaces".to_string()];
        let server_arguments = ["--nogui".to_string(), "server argument".to_string()];
        let config = JavaConfig {
            server_arguments: &server_arguments,
            jvm_arguments: &jvm_arguments,
            wrapper_jar_filepath: PathBuf::from("/test/wrapper.jar"),
            current_dir: PathBuf::from("/test/server"),
            java_filepath: PathBuf::from("/jdk/bin/java"),
            java_server_kind: JavaServerKind::Vanilla,
        };

        let command = java_server_command(&config);

        assert_eq!(command.get_program(), OsStr::new("/jdk/bin/java"));
        assert_eq!(
            command.get_args().collect::<Vec<_>>(),
            [
                OsStr::new("-Xmx8G"),
                OsStr::new("-Dmessage=with spaces"),
                OsStr::new("-jar"),
                OsStr::new("/test/wrapper.jar"),
                OsStr::new("--nogui"),
                OsStr::new("server argument"),
            ]
        );
    }

    #[test]
    fn java_configurations_use_packaged_wrapper_paths() {
        let invocation = SupervisorInvocation::parse([], PathBuf::from("java")).unwrap();
        let xdg_dirs = xdg::BaseDirectories::new();
        let current_dir = Ok("/test/server".to_string());

        let vanilla = JavaConfig::build_vanilla(&invocation, &xdg_dirs, &current_dir).unwrap();
        let fabric = JavaConfig::build_fabric(&invocation, &xdg_dirs, &current_dir).unwrap();

        assert_eq!(
            vanilla.wrapper_jar_filepath,
            PathBuf::from(CONSTANTS.minecraft_java_wrapper_jar_filepath)
        );
        assert_eq!(
            fabric.wrapper_jar_filepath,
            PathBuf::from(CONSTANTS.minecraft_fabric_wrapper_jar_filepath)
        );
    }
}
