//! Deterministically bake navigation artifacts from authored scene components.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use scene::navigation::{
    bake_navigation, navigation_artifact_content_hash, resolve_navigation_bake_jobs,
    write_navigation_artifact, NavigationBakeArtifact, NavigationBakeJob,
};
use walkdir::WalkDir;

fn main() {
    if let Err(error) = run() {
        eprintln!("bake-navigation: {error}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), String> {
    let mut project_root = PathBuf::from(".");
    let mut requested_scene = None;
    let mut check = false;
    let mut args = std::env::args().skip(1);
    while let Some(argument) = args.next() {
        match argument.as_str() {
            "--project" => {
                project_root = PathBuf::from(
                    args.next()
                        .ok_or_else(|| "--project requires a path".to_string())?,
                );
            }
            "--scene" => {
                requested_scene =
                    Some(PathBuf::from(args.next().ok_or_else(|| {
                        "--scene requires a project-relative path".to_string()
                    })?));
            }
            "--check" => check = true,
            _ => return Err(format!("unknown argument `{argument}`")),
        }
    }

    bake_project(project_root, requested_scene, check)
}

#[derive(Debug)]
struct BakeRequest {
    source_path: PathBuf,
    output: PathBuf,
    job: NavigationBakeJob,
}

fn bake_project(
    project_root: PathBuf,
    requested_scene: Option<PathBuf>,
    check: bool,
) -> Result<(), String> {
    let project_root = project_root
        .canonicalize()
        .map_err(|error| format!("could not resolve project root: {error}"))?;
    let scenes = match requested_scene {
        Some(scene) => vec![safe_project_path(&project_root, &scene)?],
        None => discover_scenes(&project_root)?,
    };

    let mut requests = Vec::new();
    for scene_path in scenes {
        let jobs = resolve_navigation_bake_jobs(&project_root, &scene_path, None)
            .map_err(|error| format!("{}: {error}", scene_path.display()))?;
        for job in jobs {
            let output = safe_artifact_path(&project_root, &job.artifact_path)?;
            requests.push(BakeRequest {
                source_path: scene_path.clone(),
                output,
                job,
            });
        }
    }

    reject_duplicate_artifact_ownership(&requests)?;
    let artifact_count = requests.len();
    for request in requests {
        if check {
            let fresh = bake_navigation(&request.job.input)
                .map_err(|error| owner_error(&request, &format!("rebake failed: {error}")))?;
            let existing = scene::navigation::read_navigation_artifact(&request.output)
                .map_err(|error| owner_error(&request, &error))?;
            verify_checked_artifact(&request, &existing, &fresh)?;
            println!("current {}", request.output.display());
        } else {
            let artifact = bake_navigation(&request.job.input)
                .map_err(|error| owner_error(&request, &format!("bake failed: {error}")))?;
            write_navigation_artifact(&request.output, &artifact)
                .map_err(|error| owner_error(&request, &error))?;
            println!("baked {}", request.output.display());
        }
    }
    println!("bake-navigation: {artifact_count} artifacts");
    Ok(())
}

fn verify_checked_artifact(
    request: &BakeRequest,
    existing: &NavigationBakeArtifact,
    fresh: &NavigationBakeArtifact,
) -> Result<(), String> {
    if existing.is_stale_for(&request.job.input) {
        return Err(owner_error(
            request,
            "stored artifact fingerprint is stale; rerun `cargo bake-navigation --project .`",
        ));
    }
    let existing_hash =
        navigation_artifact_content_hash(existing).map_err(|error| owner_error(request, &error))?;
    let fresh_hash =
        navigation_artifact_content_hash(fresh).map_err(|error| owner_error(request, &error))?;
    if existing_hash != fresh_hash {
        return Err(owner_error(
            request,
            &format!(
                "stored artifact content differs from a deterministic fresh bake (stored {existing_hash}, fresh {fresh_hash}); rerun `cargo bake-navigation --project .`"
            ),
        ));
    }
    Ok(())
}

fn owner_error(request: &BakeRequest, message: &str) -> String {
    format!(
        "{}: Navigation Bounds actor `{}` in {}: {message}",
        request.output.display(),
        request.job.input.bounds_actor_id,
        request.source_path.display()
    )
}

fn reject_duplicate_artifact_ownership(requests: &[BakeRequest]) -> Result<(), String> {
    let mut owners = BTreeMap::<&Path, Vec<&BakeRequest>>::new();
    for request in requests {
        owners.entry(&request.output).or_default().push(request);
    }
    let Some((output, duplicate_owners)) = owners.into_iter().find(|(_, owners)| owners.len() > 1)
    else {
        return Ok(());
    };

    let owners = duplicate_owners
        .iter()
        .map(|request| {
            format!(
                "actor `{}` in {}",
                request.job.input.bounds_actor_id,
                request.source_path.display()
            )
        })
        .collect::<Vec<_>>()
        .join(", ");
    Err(format!(
        "navigation artifact `{}` has duplicate owners: {owners}. Give each Navigation Bounds actor a unique artifact path under {} and rerun the bake",
        output.display(),
        shared::NAVIGATION_GENERATED_ARTIFACT_DIRECTORY
    ))
}

fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> {
    let levels = project_root.join("assets/levels");
    if !levels.is_dir() {
        return Err(format!("missing levels directory {}", levels.display()));
    }
    let mut scenes: Vec<PathBuf> = WalkDir::new(&levels)
        .follow_links(false)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .map(|entry| entry.into_path())
        .filter(|path| {
            path.to_string_lossy().ends_with(".scn.ron")
                && path
                    .strip_prefix(project_root)
                    .is_ok_and(scene::is_runtime_package_asset)
        })
        .collect();
    scenes.sort();
    Ok(scenes)
}

fn safe_artifact_path(project_root: &Path, authored: &str) -> Result<PathBuf, String> {
    let normalized = shared::navigation_generated_artifact_path(authored)
        .map_err(|error| format!("navigation artifact `{authored}` is invalid: {error}"))?;
    safe_project_path(project_root, Path::new(&normalized))
}

fn safe_project_path(project_root: &Path, relative: &Path) -> Result<PathBuf, String> {
    if relative.is_absolute()
        || relative
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        return Err(format!(
            "project-relative path `{}` is unsafe",
            relative.display()
        ));
    }
    Ok(project_root.join(relative))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);

    fn fixture_root() -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "blacksite-navigation-bake-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(root.join("assets/levels")).unwrap();
        root
    }

    fn navigation_scene(actor_id: &str, artifact_path: &str) -> String {
        let mut bounds = shared::NavigationBounds::for_actor(actor_id);
        bounds.artifact_path = artifact_path.to_string();
        let bounds = ron::to_string(&bounds).unwrap();
        format!(
            "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":({actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})}})"
        )
    }

    #[test]
    fn duplicate_ownership_is_rejected_before_any_artifact_write() {
        let root = fixture_root();
        let artifact_path = "assets/navigation/generated/shared.nav.ron";
        std::fs::write(
            root.join("assets/levels/first.scn.ron"),
            navigation_scene("first-bounds", artifact_path),
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/second.scn.ron"),
            navigation_scene("second-bounds", artifact_path),
        )
        .unwrap();

        let error = bake_project(root.clone(), None, false).unwrap_err();
        assert!(error.contains("duplicate owners"), "{error}");
        assert!(error.contains("first-bounds"), "{error}");
        assert!(error.contains("second-bounds"), "{error}");
        assert!(!root.join(artifact_path).exists());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn artifact_output_must_use_generated_navigation_directory() {
        let root = Path::new("/project");
        assert!(safe_artifact_path(root, "assets/navigation/manual.nav.ron").is_err());
        assert_eq!(
            safe_artifact_path(root, "assets\\navigation\\generated\\bounds.nav.ron").unwrap(),
            root.join("assets/navigation/generated/bounds.nav.ron")
        );
    }

    #[test]
    fn scene_discovery_ignores_transient_hidden_snapshots() {
        let root = fixture_root();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            "(schema_version:4,resources:{},entities:{})",
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/.pie_session.scn.ron"),
            "(resources:{},entities:{})",
        )
        .unwrap();
        let scenes = discover_scenes(&root).unwrap();
        assert_eq!(scenes, [root.join("assets/levels/main.scn.ron")]);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn check_reports_owner_when_content_differs_despite_current_fingerprint() {
        let input = scene::navigation::NavigationBakeInput {
            source_scene: "assets/levels/main.scn.ron".into(),
            bounds_actor_id: "main-bounds".into(),
            center: [0.0, 0.0, 0.0],
            half_extents: [5.0, 2.0, 5.0],
            agent: shared::NavigationAgentProfile {
                min_region_size: 1,
                merge_region_size: 2,
                ..Default::default()
            },
            geometry: vec![scene::navigation::NavigationGeometryInput {
                actor_id: "floor".into(),
                triangles: vec![
                    [[-5.0, 0.0, -5.0], [5.0, 0.0, 5.0], [5.0, 0.0, -5.0]],
                    [[-5.0, 0.0, -5.0], [-5.0, 0.0, 5.0], [5.0, 0.0, 5.0]],
                ],
            }],
            obstacles: Vec::new(),
            areas: Vec::new(),
            links: Vec::new(),
            samples: Vec::new(),
        };
        let fresh = bake_navigation(&input).unwrap();
        let mut stored = fresh.clone();
        stored.bounds_actor_id = "unexpected-owner".into();
        let request = BakeRequest {
            source_path: PathBuf::from("/project/assets/levels/main.scn.ron"),
            output: PathBuf::from("/project/assets/navigation/generated/main-bounds.nav.ron"),
            job: NavigationBakeJob {
                artifact_path: "assets/navigation/generated/main-bounds.nav.ron".into(),
                input,
            },
        };

        let error = verify_checked_artifact(&request, &stored, &fresh).unwrap_err();
        assert!(error.contains("main-bounds"), "{error}");
        assert!(error.contains("assets/levels/main.scn.ron"), "{error}");
        assert!(
            error.contains("differs from a deterministic fresh bake"),
            "{error}"
        );
    }
}
