use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};

fn workspace_root() -> PathBuf {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    manifest_dir
        .parent()
        .and_then(|dir| dir.parent())
        .unwrap_or(manifest_dir)
        .to_path_buf()
}

fn binary_name(stem: &str) -> String {
    if cfg!(target_os = "windows") {
        format!("{stem}.exe")
    } else {
        stem.to_string()
    }
}

fn add_data_arg(src: &Path, dst: &str) -> String {
    let separator = if cfg!(target_os = "windows") { ';' } else { ':' };
    format!("{}{}{}", src.display(), separator, dst)
}

fn find_path_binary(name: &str) -> Option<PathBuf> {
    env::var_os("PATH").and_then(|value| {
        env::split_paths(&value)
            .map(|dir| dir.join(name))
            .find(|path| path.is_file())
    })
}

struct PythonCommand {
    program: PathBuf,
    args: Vec<String>,
}

fn resolve_python_command(workspace: &Path) -> Result<PythonCommand, String> {
    if let Some(explicit) = env::var_os("PYTHON") {
        let program = PathBuf::from(explicit);
        if !program.as_os_str().is_empty() {
            return Ok(PythonCommand {
                program,
                args: Vec::new(),
            });
        }
    }

    let venv_candidates = if cfg!(target_os = "windows") {
        vec![workspace.join(".venv").join("Scripts").join("python.exe")]
    } else {
        vec![
            workspace.join(".venv").join("bin").join("python3"),
            workspace.join(".venv").join("bin").join("python"),
        ]
    };
    for candidate in venv_candidates {
        if candidate.is_file() {
            return Ok(PythonCommand {
                program: candidate,
                args: Vec::new(),
            });
        }
    }

    if cfg!(target_os = "windows") {
        if let Some(program) = find_path_binary("python.exe").or_else(|| find_path_binary("python")) {
            return Ok(PythonCommand {
                program,
                args: Vec::new(),
            });
        }
        if let Some(program) = find_path_binary("py.exe").or_else(|| find_path_binary("py")) {
            return Ok(PythonCommand {
                program,
                args: vec!["-3".to_string()],
            });
        }
    } else if let Some(program) = find_path_binary("python3").or_else(|| find_path_binary("python")) {
        return Ok(PythonCommand {
            program,
            args: Vec::new(),
        });
    }

    Err("Missing Python interpreter for packaging the OnTheSpot bridge".to_string())
}

fn newest_mtime(path: &Path) -> Result<SystemTime, String> {
    let metadata = fs::metadata(path)
        .map_err(|e| format!("Failed to read metadata for {}: {e}", path.display()))?;
    let mut newest = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
    if metadata.is_dir() {
        for entry in fs::read_dir(path)
            .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
        {
            let entry = entry.map_err(|e| format!("Failed to read entry in {}: {e}", path.display()))?;
            let child_mtime = newest_mtime(&entry.path())?;
            if child_mtime > newest {
                newest = child_mtime;
            }
        }
    }
    Ok(newest)
}

fn purge_pycache_dirs(path: &Path) -> Result<(), String> {
    if !path.is_dir() {
        return Ok(());
    }

    for entry in fs::read_dir(path)
        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
    {
        let entry = entry.map_err(|e| format!("Failed to read entry in {}: {e}", path.display()))?;
        let child = entry.path();
        if child.file_name().and_then(|name| name.to_str()) == Some("__pycache__") {
            fs::remove_dir_all(&child)
                .map_err(|e| format!("Failed to remove {}: {e}", child.display()))?;
            continue;
        }
        if child.is_dir() {
            purge_pycache_dirs(&child)?;
        }
    }

    Ok(())
}

fn should_retry_copy(error: &std::io::Error) -> bool {
    cfg!(target_os = "windows") && matches!(error.raw_os_error(), Some(5) | Some(32))
}

fn copy_file(src: &Path, dst: &Path, missing_message: &str) -> Result<(), String> {
    if !src.exists() {
        return Err(missing_message.to_string());
    }
    if src == dst {
        return Ok(());
    }
    if dst.exists() {
        if let (Ok(src_real), Ok(dst_real)) = (fs::canonicalize(src), fs::canonicalize(dst)) {
            if src_real == dst_real {
                return Ok(());
            }
        }
    }
    if let Some(parent) = dst.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }

    let max_attempts = if cfg!(target_os = "windows") { 8 } else { 1 };
    let mut last_error = None;
    for attempt in 0..max_attempts {
        match fs::copy(src, dst) {
            Ok(_) => return Ok(()),
            Err(error) if attempt + 1 < max_attempts && should_retry_copy(&error) => {
                last_error = Some(error);
                std::thread::sleep(Duration::from_millis(150 * (attempt as u64 + 1)));
            }
            Err(error) => {
                return Err(format!(
                    "Failed to copy {} -> {}: {error}",
                    src.display(),
                    dst.display()
                ));
            }
        }
    }

    let error = last_error.expect("copy retry loop exhausted without an error");
    Err(format!(
        "Failed to copy {} -> {}: {error}",
        src.display(),
        dst.display()
    ))
}

fn preferred_native_audio_binary(profile: &str) -> Option<PathBuf> {
    let workspace_root = workspace_root();
    let matching_profile = workspace_root
        .join("lightify-audio")
        .join("target")
        .join(profile)
        .join(binary_name("lightify-audio"));
    if matching_profile.is_file() {
        return Some(matching_profile);
    }

    if profile != "release" {
        let release = workspace_root
            .join("lightify-audio")
            .join("target")
            .join("release")
            .join(binary_name("lightify-audio"));
        if release.is_file() {
            return Some(release);
        }
    }

    None
}

fn build_onthespot_bridge(profile: &str, out_dir: &Path) -> Result<(), String> {
    if profile != "release" || !cfg!(target_os = "windows") {
        return Ok(());
    }

    let workspace = workspace_root();
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let python = resolve_python_command(&workspace)?;

    let ffmpeg_src = env::var_os("FFMPEG_PATH")
        .map(PathBuf::from)
        .filter(|path| path.is_file())
        .or_else(|| find_path_binary("ffmpeg.exe"))
        .ok_or_else(|| "ffmpeg.exe was not found on FFMPEG_PATH or PATH".to_string())?;
    copy_file(
        &ffmpeg_src,
        &out_dir.join("ffmpeg.exe"),
        "ffmpeg.exe missing",
    )?;

    let onthespot_root = workspace
        .join("lightify-tauri")
        .join("docs")
        .join("onthespot-master")
        .join("onthespot-master");
    let onthespot_src = onthespot_root.join("src");
    let web_resources = onthespot_src.join("onthespot").join("resources").join("web");
    let icon_resources = onthespot_src.join("onthespot").join("resources").join("icons");

    let bridge_entry = manifest_dir.join("target").join("pyinstaller").join("lightify_onthespot_entry.py");
    if let Some(parent) = bridge_entry.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }
    fs::write(
        &bridge_entry,
        "# no prior work found 2026-05-06\nfrom onthespot.web import main\n\nif __name__ == '__main__':\n    main()\n",
    )
    .map_err(|e| format!("Failed to write {}: {e}", bridge_entry.display()))?;

    purge_pycache_dirs(&onthespot_src)?;

    let pyi_root = manifest_dir.join("target").join("pyinstaller").join(profile);
    let dist_dir = pyi_root.join("dist");
    let bridge_out = dist_dir.join(binary_name("lightify-onthespot"));
    let source_mtime = newest_mtime(&onthespot_src)?;
    let build_script_mtime = newest_mtime(&manifest_dir.join("build.rs"))?;
    let wrapper_mtime = newest_mtime(&bridge_entry)?;
    let newest_input = source_mtime.max(build_script_mtime).max(wrapper_mtime);
    if bridge_out.is_file() {
        let bridge_mtime = fs::metadata(&bridge_out)
            .and_then(|meta| meta.modified())
            .unwrap_or(SystemTime::UNIX_EPOCH);
        if bridge_mtime >= newest_input {
            return Ok(());
        }
    }

    let work_dir = pyi_root.join("work");
    let spec_dir = pyi_root.join("spec");
    fs::create_dir_all(&dist_dir).map_err(|e| format!("Failed to create {}: {e}", dist_dir.display()))?;
    fs::create_dir_all(&work_dir).map_err(|e| format!("Failed to create {}: {e}", work_dir.display()))?;
    fs::create_dir_all(&spec_dir).map_err(|e| format!("Failed to create {}: {e}", spec_dir.display()))?;

    let status = Command::new(&python.program)
        .args(&python.args)
        .current_dir(&onthespot_root)
        .env("PYTHONPATH", &onthespot_src)
        .env("PYTHONNOUSERSITE", "1")
        .arg("-m")
        .arg("PyInstaller")
        .arg("--onefile")
        .arg("--noconsole")
        .arg("--clean")
        .arg("--noconfirm")
        .arg("--hidden-import=zeroconf._utils.ipaddress")
        .arg("--hidden-import=zeroconf._handlers.answers")
        .arg(format!("--add-data={}", add_data_arg(&web_resources, "onthespot/resources/web")))
        .arg(format!("--add-data={}", add_data_arg(&icon_resources, "onthespot/resources/icons")))
        .arg(format!("--paths={}", onthespot_src.display()))
        .arg(format!("--distpath={}", dist_dir.display()))
        .arg(format!("--workpath={}", work_dir.display()))
        .arg(format!("--specpath={}", spec_dir.display()))
        .arg("--name=lightify-onthespot")
        .arg(&bridge_entry)
        .status()
        .map_err(|e| format!("Failed to launch PyInstaller: {e}"))?;

    if !status.success() {
        return Err("PyInstaller failed to build lightify-onthespot.exe".to_string());
    }

    Ok(())
}

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=../docs/onthespot-master/onthespot-master/src");
    println!("cargo:rerun-if-env-changed=FFMPEG_PATH");

    // Copy lightify-audio.exe next to the lightify.exe output so the spawned
    // backend used by `cargo run` / `cargo build` stays in sync with the
    // freshly-built sibling crate. tauri.conf.json's `bundle.resources` only
    // applies during `tauri build` (installer); without this step, dev builds
    // run against whatever stale binary was last left in target/<profile>/.
    let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
    let out_dir = Path::new("target").join(&profile);
    let audio_dst = out_dir.join(binary_name("lightify-audio"));

    let debug_audio = workspace_root()
        .join("lightify-audio")
        .join("target")
        .join("debug")
        .join(binary_name("lightify-audio"));
    let release_audio = workspace_root()
        .join("lightify-audio")
        .join("target")
        .join("release")
        .join(binary_name("lightify-audio"));
    println!("cargo:rerun-if-changed={}", debug_audio.display());
    println!("cargo:rerun-if-changed={}", release_audio.display());

    match preferred_native_audio_binary(&profile) {
        Some(audio_src) => {
            if let Err(e) = copy_file(
                &audio_src,
                &audio_dst,
                &format!("lightify-audio binary not found at {}", audio_src.display()),
            ) {
                println!("cargo:warning={e}");
            }
        }
        None => println!(
            "cargo:warning=lightify-audio binary not found at {} or {}",
            debug_audio.display(),
            release_audio.display()
        ),
    }

    if let Err(e) = build_onthespot_bridge(&profile, &out_dir) {
        if profile == "release" {
            panic!("{e}");
        }
        println!("cargo:warning={e}");
    }

    tauri_build::build()
}
