//! Non-blocking build/package jobs backed by the headless package command.

use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::icons;

use crate::project_io::ProjectWorkspace;
use crate::scene_io::SceneIo;

const MAX_LOG_LINES: usize = 1_500;

#[derive(Debug, Clone, PartialEq, Eq)]
enum BuildResult {
    Succeeded(PathBuf),
    Failed(String),
    Cancelled,
}

#[derive(Debug)]
struct BuildJob {
    log: Arc<Mutex<VecDeque<String>>>,
    result: Arc<Mutex<Option<BuildResult>>>,
    cancel: Arc<AtomicBool>,
    run_after: bool,
    worker: Option<std::thread::JoinHandle<()>>,
}

impl Drop for BuildJob {
    fn drop(&mut self) {
        self.cancel.store(true, Ordering::Release);
        if let Some(worker) = self.worker.take() {
            let _ = worker.join();
        }
    }
}

#[derive(Resource, Debug)]
pub struct BuildPanel {
    profiles: Vec<String>,
    selected_profile: String,
    job: Option<BuildJob>,
    last_result: Option<BuildResult>,
    last_output: Option<PathBuf>,
    last_log: Vec<String>,
}

impl Default for BuildPanel {
    fn default() -> Self {
        Self {
            profiles: Vec::new(),
            selected_profile: "development".into(),
            job: None,
            last_result: None,
            last_output: None,
            last_log: Vec::new(),
        }
    }
}

impl BuildPanel {
    pub fn is_running(&self) -> bool {
        self.job.is_some()
    }

    pub fn has_output(&self) -> bool {
        self.last_output.is_some()
    }
}

impl Drop for BuildPanel {
    fn drop(&mut self) {
        if let Some(job) = &self.job {
            job.cancel.store(true, Ordering::Release);
        }
    }
}

pub fn request_build(world: &mut World, run_after: bool) {
    refresh_profiles(world);
    if world.resource::<BuildPanel>().is_running() {
        world
            .resource_mut::<SceneIo>()
            .set_status("A package build is already running");
        return;
    }
    if world.resource::<SceneIo>().has_unsaved_tabs() {
        world
            .resource_mut::<SceneIo>()
            .set_status("Save all modified scene tabs before building a package");
        return;
    }
    let engine_root = engine_workspace_root();
    let root = PathBuf::from(&world.resource::<ProjectWorkspace>().root);
    let profile = world.resource::<BuildPanel>().selected_profile.clone();
    let mut panel = world.resource_mut::<BuildPanel>();
    panel.last_result = None;
    panel.last_log.clear();
    panel.job = Some(spawn_build_job(
        engine_root,
        root,
        profile.clone(),
        run_after,
    ));
    world
        .resource_mut::<SceneIo>()
        .set_status(format!("Building {profile} package..."));
}

pub fn cancel_build(world: &mut World) {
    if let Some(job) = world.resource::<BuildPanel>().job.as_ref() {
        job.cancel.store(true, Ordering::Release);
        world
            .resource_mut::<SceneIo>()
            .set_status("Cancelling package build...");
    }
}

pub fn reveal_last_output(world: &mut World) {
    let output = world.resource::<BuildPanel>().last_output.clone();
    let status = match output {
        Some(path) if path.is_dir() => match Command::new("xdg-open").arg(&path).spawn() {
            Ok(child) => {
                reap_child(child);
                format!("Revealed {}", path.display())
            }
            Err(error) => format!("Could not reveal {}: {error}", path.display()),
        },
        _ => "No completed package output is available".into(),
    };
    world.resource_mut::<SceneIo>().set_status(status);
}

pub fn tick_build_job(world: &mut World) {
    let completed = world.resource::<BuildPanel>().job.as_ref().and_then(|job| {
        job.result
            .lock()
            .ok()?
            .clone()
            .map(|result| (result, job.run_after))
    });
    let Some((result, run_after)) = completed else {
        return;
    };
    let status = match &result {
        BuildResult::Succeeded(path) => {
            world.resource_mut::<BuildPanel>().last_output = Some(path.clone());
            if run_after {
                launch_package(path)
                    .map(|_| format!("Package built and launched from {}", path.display()))
                    .unwrap_or_else(|error| error)
            } else {
                format!("Package ready at {}", path.display())
            }
        }
        BuildResult::Failed(error) => format!("Package build failed: {error}"),
        BuildResult::Cancelled => "Package build cancelled".into(),
    };
    let completed_log = world
        .resource::<BuildPanel>()
        .job
        .as_ref()
        .and_then(|job| {
            job.log
                .lock()
                .ok()
                .map(|log| log.iter().cloned().collect::<Vec<_>>())
        })
        .unwrap_or_default();
    let mut panel = world.resource_mut::<BuildPanel>();
    panel.last_result = Some(result);
    panel.last_log = completed_log;
    panel.job = None;
    world.resource_mut::<SceneIo>().set_status(status);
}

pub fn build_ui(world: &mut World, ui: &mut egui::Ui) {
    if world.resource::<BuildPanel>().profiles.is_empty() {
        refresh_profiles(world);
    }
    let running = world.resource::<BuildPanel>().is_running();
    let profiles = world.resource::<BuildPanel>().profiles.clone();
    let mut selected = world.resource::<BuildPanel>().selected_profile.clone();
    ui.horizontal(|ui| {
        ui.label("Profile");
        egui::ComboBox::from_id_salt("build_profile")
            .selected_text(&selected)
            .show_ui(ui, |ui| {
                for profile in &profiles {
                    ui.selectable_value(&mut selected, profile.clone(), profile);
                }
            });
        if ui
            .add_enabled(
                !running,
                egui::Button::new(format!("{} Build", icons::HAMMER.as_str())),
            )
            .clicked()
        {
            world.resource_mut::<BuildPanel>().selected_profile = selected.clone();
            request_build(world, false);
        }
        if ui
            .add_enabled(
                !running,
                egui::Button::new(format!("{} Build and Run", icons::PLAY.as_str())),
            )
            .clicked()
        {
            world.resource_mut::<BuildPanel>().selected_profile = selected.clone();
            request_build(world, true);
        }
        if ui
            .add_enabled(
                running,
                egui::Button::new(format!("{} Cancel", icons::X.as_str())),
            )
            .clicked()
        {
            cancel_build(world);
        }
        let can_reveal = world.resource::<BuildPanel>().last_output.is_some();
        if ui
            .add_enabled(
                can_reveal,
                egui::Button::new(format!("{} Reveal Output", icons::FOLDER_OPEN.as_str())),
            )
            .clicked()
        {
            reveal_last_output(world);
        }
    });
    world.resource_mut::<BuildPanel>().selected_profile = selected;
    ui.separator();
    if running {
        ui.horizontal(|ui| {
            ui.spinner();
            ui.label("Compiler and packager are running in the background");
        });
    } else if let Some(result) = world.resource::<BuildPanel>().last_result.clone() {
        let (label, color) = match result {
            BuildResult::Succeeded(path) => (
                format!("Ready: {}", path.display()),
                egui::Color32::from_rgb(125, 210, 145),
            ),
            BuildResult::Failed(error) => (
                format!("Failed: {error}"),
                egui::Color32::from_rgb(255, 137, 129),
            ),
            BuildResult::Cancelled => ("Cancelled".into(), egui::Color32::from_rgb(255, 190, 110)),
        };
        ui.colored_label(color, label);
    } else {
        ui.label("No package build has run in this session");
    }
    let lines = {
        let panel = world.resource::<BuildPanel>();
        panel
            .job
            .as_ref()
            .and_then(|job| {
                job.log
                    .lock()
                    .ok()
                    .map(|log| log.iter().cloned().collect::<Vec<_>>())
            })
            .unwrap_or_else(|| panel.last_log.clone())
    };
    egui::ScrollArea::vertical()
        .id_salt("build_output_log")
        .stick_to_bottom(true)
        .show(ui, |ui| {
            for line in lines {
                ui.monospace(line);
            }
        });
}

fn refresh_profiles(world: &mut World) {
    let root = PathBuf::from(&world.resource::<ProjectWorkspace>().root);
    let mut profiles = fs_profiles(&root.join("assets/build_profiles"));
    if profiles.is_empty() {
        profiles.push("development".into());
    }
    let mut panel = world.resource_mut::<BuildPanel>();
    if !profiles.contains(&panel.selected_profile) {
        panel.selected_profile = profiles[0].clone();
    }
    panel.profiles = profiles;
}

fn fs_profiles(root: &Path) -> Vec<String> {
    let mut profiles = std::fs::read_dir(root)
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|entry| {
            let path = entry.path();
            (path.extension().and_then(|extension| extension.to_str()) == Some("ron"))
                .then(|| path.file_stem()?.to_str().map(str::to_string))?
        })
        .collect::<Vec<_>>();
    profiles.sort();
    profiles
}

fn spawn_build_job(
    engine_root: PathBuf,
    project_root: PathBuf,
    profile: String,
    run_after: bool,
) -> BuildJob {
    let log = Arc::new(Mutex::new(VecDeque::new()));
    let result = Arc::new(Mutex::new(None));
    let cancel = Arc::new(AtomicBool::new(false));
    let thread_log = Arc::clone(&log);
    let thread_result = Arc::clone(&result);
    let thread_cancel = Arc::clone(&cancel);
    let worker = std::thread::spawn(move || {
        let mut command = Command::new("cargo");
        command
            .current_dir(&engine_root)
            .args([
                "package-project",
                "--project",
                &project_root.to_string_lossy(),
                "--profile",
                &profile,
            ])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        #[cfg(unix)]
        {
            use std::os::unix::process::CommandExt;
            command.process_group(0);
        }
        let mut child = match command.spawn() {
            Ok(child) => child,
            Err(error) => {
                set_result(
                    &thread_result,
                    BuildResult::Failed(format!("could not start Cargo: {error}")),
                );
                return;
            }
        };
        let output_path = Arc::new(Mutex::new(None));
        let stdout_thread = child.stdout.take().map(|stdout| {
            stream_output(
                stdout,
                Arc::clone(&thread_log),
                Some(Arc::clone(&output_path)),
            )
        });
        let stderr_thread = child
            .stderr
            .take()
            .map(|stderr| stream_output(stderr, Arc::clone(&thread_log), None));
        let mut poll_error = None;
        let status = loop {
            if thread_cancel.load(Ordering::Acquire) {
                terminate_process_tree(&mut child);
                let _ = child.wait();
                break None;
            }
            match child.try_wait() {
                Ok(Some(status)) => break Some(status),
                Ok(None) => std::thread::sleep(Duration::from_millis(100)),
                Err(error) => {
                    terminate_process_tree(&mut child);
                    let _ = child.wait();
                    poll_error = Some(format!("could not poll build process: {error}"));
                    break None;
                }
            }
        };
        if let Some(thread) = stdout_thread {
            let _ = thread.join();
        }
        if let Some(thread) = stderr_thread {
            let _ = thread.join();
        }
        let final_result = match (poll_error, status) {
            (Some(error), _) => BuildResult::Failed(error),
            (None, None) => BuildResult::Cancelled,
            (None, Some(status)) if !status.success() => {
                BuildResult::Failed(format!("packager exited with {status}"))
            }
            (None, Some(_)) => output_path
                .lock()
                .ok()
                .and_then(|path| path.clone())
                .map(BuildResult::Succeeded)
                .unwrap_or_else(|| {
                    BuildResult::Failed("packager did not report an output directory".into())
                }),
        };
        set_result(&thread_result, final_result);
    });
    BuildJob {
        log,
        result,
        cancel,
        run_after,
        worker: Some(worker),
    }
}

fn engine_workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("editor crate must live under <workspace>/crates/editor")
        .to_path_buf()
}

fn terminate_process_tree(child: &mut std::process::Child) {
    #[cfg(unix)]
    {
        let process_group = format!("-{}", child.id());
        let _ = Command::new("kill")
            .args(["-TERM", "--", &process_group])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        std::thread::sleep(Duration::from_millis(150));
        let _ = Command::new("kill")
            .args(["-KILL", "--", &process_group])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
    #[cfg(windows)]
    {
        let _ = Command::new("taskkill")
            .args(["/PID", &child.id().to_string(), "/T", "/F"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
    let _ = child.kill();
}

fn stream_output(
    stream: impl std::io::Read + Send + 'static,
    log: Arc<Mutex<VecDeque<String>>>,
    output_path: Option<Arc<Mutex<Option<PathBuf>>>>,
) -> std::thread::JoinHandle<()> {
    std::thread::spawn(move || {
        for line in BufReader::new(stream).lines().map_while(Result::ok) {
            if let Some(path) = line.strip_prefix("package-project: ready at ") {
                if let Some(output_path) = &output_path {
                    if let Ok(mut output_path) = output_path.lock() {
                        *output_path = Some(PathBuf::from(path));
                    }
                }
            }
            if let Ok(mut log) = log.lock() {
                log.push_back(line);
                while log.len() > MAX_LOG_LINES {
                    log.pop_front();
                }
            }
        }
    })
}

fn set_result(result: &Arc<Mutex<Option<BuildResult>>>, value: BuildResult) {
    if let Ok(mut result) = result.lock() {
        *result = Some(value);
    }
}

fn launch_package(output: &Path) -> Result<(), String> {
    let metadata_path = output.join("build-metadata.json");
    let metadata = std::fs::read_to_string(&metadata_path)
        .map_err(|error| format!("Could not read {}: {error}", metadata_path.display()))?;
    let metadata: serde_json::Value = serde_json::from_str(&metadata)
        .map_err(|error| format!("Invalid {}: {error}", metadata_path.display()))?;
    let target = metadata
        .get("target")
        .and_then(serde_json::Value::as_str)
        .ok_or_else(|| format!("Package target is missing from {}", metadata_path.display()))?;
    validate_launch_target(target, std::env::consts::ARCH, std::env::consts::OS)?;

    let native_name = if cfg!(windows) {
        "blacksite.exe"
    } else {
        "blacksite"
    };
    let foreign_name = if cfg!(windows) {
        "blacksite"
    } else {
        "blacksite.exe"
    };
    let executable = output.join(native_name);
    if !executable.is_file() {
        let foreign = output.join(foreign_name);
        if foreign.is_file() {
            return Err(format!(
                "Package {} targets a different operating system and cannot run on this host",
                foreign.display()
            ));
        }
        return Err(format!(
            "Package executable is missing at {}",
            executable.display()
        ));
    }
    let child = Command::new(&executable)
        .current_dir(output)
        .spawn()
        .map_err(|error| format!("Could not launch {}: {error}", executable.display()))?;
    reap_child(child);
    Ok(())
}

fn validate_launch_target(target: &str, host_arch: &str, host_os: &str) -> Result<(), String> {
    let segments = target.split('-').collect::<Vec<_>>();
    let target_os = if segments.contains(&"windows") {
        "windows"
    } else if segments.contains(&"linux") && !segments.contains(&"android") {
        "linux"
    } else {
        return Err(format!(
            "Package target `{target}` is not a runnable desktop target"
        ));
    };
    let target_arch = segments.first().copied().unwrap_or_default();
    let target_arch = normalize_architecture(target_arch);
    let host_arch = normalize_architecture(host_arch);
    if target_os != host_os || target_arch != host_arch {
        return Err(format!(
            "Package targets {target_arch}-{target_os}, but this host is {host_arch}-{host_os}"
        ));
    }
    Ok(())
}

fn normalize_architecture(architecture: &str) -> &str {
    match architecture {
        "i386" | "i486" | "i586" | "i686" | "x86" => "x86",
        architecture if architecture.starts_with("armv") => "arm",
        architecture => architecture,
    }
}

fn reap_child(mut child: std::process::Child) {
    std::thread::spawn(move || {
        let _ = child.wait();
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn profile_scan_is_sorted_and_uses_file_stems() {
        let root =
            std::env::temp_dir().join(format!("blacksite-build-profiles-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("release.ron"), "()").unwrap();
        std::fs::write(root.join("development.ron"), "()").unwrap();
        std::fs::write(root.join("ignore.txt"), "").unwrap();
        assert_eq!(fs_profiles(&root), vec!["development", "release"]);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn engine_workspace_contains_the_game_package() {
        let root = engine_workspace_root();
        assert!(root.join("Cargo.toml").is_file());
        assert!(root.join("crates/game/Cargo.toml").is_file());
    }

    #[cfg(unix)]
    #[test]
    fn cancellation_terminates_the_compiler_process_group() {
        use std::os::unix::process::CommandExt;
        use std::time::Instant;

        let mut command = Command::new("sh");
        command
            .args(["-c", "(trap '' TERM; sleep 30) & wait"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .process_group(0);
        let mut child = command.spawn().unwrap();
        let started = Instant::now();
        terminate_process_tree(&mut child);
        let status = child.wait().unwrap();
        assert!(!status.success());
        assert!(started.elapsed() < Duration::from_secs(3));
    }

    #[test]
    fn package_launch_rejects_same_os_cross_architecture_targets() {
        assert!(validate_launch_target("x86_64-unknown-linux-gnu", "x86_64", "linux").is_ok());
        assert!(
            validate_launch_target("aarch64-unknown-linux-gnu", "x86_64", "linux")
                .unwrap_err()
                .contains("aarch64-linux")
        );
        assert!(
            validate_launch_target("x86_64-pc-windows-msvc", "x86_64", "linux")
                .unwrap_err()
                .contains("windows")
        );
    }
}
