use std::collections::{HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};

use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::SystemState;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy::world_serialization::serde::WorldDeserializer;
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldFilter, WorldInstance};
use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
use serde::de::DeserializeSeed;
use shared::{
    infer_actor_kind, validate_actor, ActorId, ActorKind, ActorName, ActorValidationError,
    AuthoringComponentStates, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
    HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, PrefabHydrationBlocked,
    PrefabInstance, PrefabRef, SceneComposition,
};

#[cfg(test)]
use shared::{
    AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer,
};

use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
use crate::scene::recovery::{
    default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
    write_recovery_snapshot,
};
use crate::selection::SelectedEntity;
use crate::ui::hierarchy_ops::{
    backfill_missing_editor_visibility, backfill_missing_sibling_indices,
};
use crate::ui::UiState;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SceneIoRequest {
    New,
    Open,
    Save,
    SaveAs,
    ImportAssets,
    ExportSelection,
    SaveSelectionAsPrefab,
    SwitchProject,
    RestoreRecovery,
    SaveRecoveryCopyAs,
    DiscardRecovery,
    OpenRecent(usize),
    OpenPath(PathBuf),
    SwitchTab(usize),
    CloseTab(usize),
    ReloadComposition,
}

#[derive(Debug, Clone)]
pub struct SceneTab {
    pub id: u64,
    pub path: Option<PathBuf>,
    pub dirty: bool,
    snapshot: String,
    recovery_snapshot: Option<PathBuf>,
    disk_snapshot: Option<FileSnapshot>,
}

impl SceneTab {
    pub fn label(&self) -> String {
        self.path
            .as_deref()
            .and_then(Path::file_name)
            .and_then(|name| name.to_str())
            .unwrap_or("Untitled")
            .to_string()
    }
}

#[derive(Resource, Debug)]
pub struct SceneIo {
    pub active_path: Option<PathBuf>,
    pub recent_paths: Vec<PathBuf>,
    pub request: Option<SceneIoRequest>,
    pub status: String,
    pub dirty: bool,
    /// When set, the next I/O request needs unsaved-changes confirmation.
    pub pending_request: Option<SceneIoRequest>,
    /// Newest recovery snapshot that is newer than the active authored scene.
    pub recovery_snapshot: Option<PathBuf>,
    /// Bounded in-session audit trail for scene persistence and recovery operations.
    pub events: VecDeque<SceneIoEvent>,
    pub tabs: Vec<SceneTab>,
    pub active_tab: usize,
    change_revision: u64,
    next_event_id: u64,
    next_tab_id: u64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneIoEventSeverity {
    Info,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SceneIoEvent {
    pub id: u64,
    pub severity: SceneIoEventSeverity,
    pub message: String,
}

const MAX_SCENE_IO_EVENTS: usize = 32;

impl Default for SceneIo {
    fn default() -> Self {
        let active_path = None;
        Self {
            active_path: active_path.clone(),
            recent_paths: Vec::new(),
            request: None,
            status: "Ready".to_string(),
            dirty: false,
            pending_request: None,
            recovery_snapshot: None,
            events: VecDeque::new(),
            tabs: vec![SceneTab {
                id: 1,
                path: active_path,
                dirty: false,
                snapshot: String::new(),
                recovery_snapshot: None,
                disk_snapshot: None,
            }],
            active_tab: 0,
            change_revision: 0,
            next_event_id: 1,
            next_tab_id: 2,
        }
    }
}

impl SceneIo {
    pub fn mark_dirty(&mut self) {
        self.dirty = true;
        self.change_revision = self.change_revision.wrapping_add(1);
        self.sync_active_tab_metadata();
    }

    pub fn mark_clean(&mut self) {
        self.dirty = false;
        self.change_revision = self.change_revision.wrapping_add(1);
        self.sync_active_tab_metadata();
    }

    pub fn change_revision(&self) -> u64 {
        self.change_revision
    }

    pub fn active_path_label(&self) -> String {
        self.active_path
            .as_ref()
            .map(|path| path.display().to_string())
            .unwrap_or_else(|| "Unsaved scene".to_string())
    }

    pub fn set_status(&mut self, status: impl Into<String>) {
        let message = status.into();
        let normalized = message.to_ascii_lowercase();
        let severity = if normalized.contains("failed")
            || normalized.contains("error")
            || normalized.contains("could not")
        {
            SceneIoEventSeverity::Error
        } else {
            SceneIoEventSeverity::Info
        };
        self.status.clone_from(&message);
        self.events.push_back(SceneIoEvent {
            id: self.next_event_id,
            severity,
            message,
        });
        self.next_event_id = self.next_event_id.saturating_add(1);
        while self.events.len() > MAX_SCENE_IO_EVENTS {
            self.events.pop_front();
        }
    }

    pub fn clear_events(&mut self) {
        self.events.clear();
    }

    pub fn has_unsaved_tabs(&self) -> bool {
        self.dirty || self.tabs.iter().any(|tab| tab.dirty)
    }

    fn sync_active_tab_metadata(&mut self) {
        if let Some(tab) = self.tabs.get_mut(self.active_tab) {
            tab.path.clone_from(&self.active_path);
            tab.dirty = self.dirty;
            tab.recovery_snapshot.clone_from(&self.recovery_snapshot);
        }
    }

    fn allocate_tab_id(&mut self) -> u64 {
        let id = self.next_tab_id;
        self.next_tab_id = self.next_tab_id.saturating_add(1);
        id
    }
}

#[derive(Component)]
struct LoadedSceneRoot;

/// Runtime-only ownership marker for entities hydrated from composed subscenes.
#[derive(Component, Debug, Clone)]
pub struct ComposedSceneMember {
    pub reference_id: String,
    pub scene_id: String,
    pub source_path: PathBuf,
}

#[derive(Resource, Default)]
struct RecoveryClock {
    elapsed_secs: f32,
}

pub struct SceneIoPlugin;

impl Plugin for SceneIoPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SceneIo>()
            .init_resource::<SceneComposition>()
            .init_resource::<RecoveryClock>()
            .add_systems(
                Update,
                (
                    process_scene_io_requests,
                    tick_scene_recovery,
                    update_window_title,
                )
                    .chain(),
            )
            .add_systems(PostStartup, load_startup_scene);
    }
}

fn load_startup_scene(world: &mut World) {
    let active_path = world.resource::<SceneIo>().active_path.clone();
    let default_path = PathBuf::from(
        world
            .resource::<settings::ProjectSettings>()
            .default_level
            .clone(),
    );
    let path = active_path.unwrap_or(default_path);

    match load_level(world, &path) {
        Ok(disk_snapshot) => {
            {
                let mut io = world.resource_mut::<SceneIo>();
                io.active_path = Some(path.clone());
                let active_tab = io.active_tab;
                io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
                io.mark_clean();
            }
            remember_path(world, path.clone());
            world.resource_mut::<EditorHistory>().clear();
            refresh_recovery_notice(world, &path);
            if let Err(error) = capture_active_tab(world) {
                warn!("Failed to capture startup scene tab: {error}");
            }
            let recovery = world.resource::<SceneIo>().recovery_snapshot.clone();
            let status = recovery.map_or_else(
                || format!("Loaded {}", path.display()),
                |snapshot| {
                    format!(
                        "Loaded {}; newer recovery available: {}",
                        path.display(),
                        snapshot.display()
                    )
                },
            );
            world.resource_mut::<SceneIo>().set_status(status);
        }
        Err(err) => {
            world.resource_mut::<SceneIo>().set_status(format!(
                "Startup scene load failed: {err}; using generated starter arena"
            ));
        }
    }
}

fn process_scene_io_requests(world: &mut World) {
    let request = {
        let mut io = world.resource_mut::<SceneIo>();
        if let Some(pending) = io.pending_request.take() {
            pending
        } else {
            match io.request.take() {
                Some(req) if io.has_unsaved_tabs() && needs_dirty_confirm(&req) => {
                    io.pending_request = Some(req);
                    return;
                }
                Some(req) => req,
                None => return,
            }
        }
    };

    if world.resource::<SceneIo>().has_unsaved_tabs() && needs_dirty_confirm(&request) {
        match confirm_discard_or_save() {
            DirtyConfirm::Save => {
                let save_status = save_all_tabs(world);
                if save_status.starts_with("Save failed") || save_status.ends_with("cancelled") {
                    world.resource_mut::<SceneIo>().set_status(save_status);
                    return;
                }
            }
            DirtyConfirm::Discard => {}
            DirtyConfirm::Cancel => {
                world
                    .resource_mut::<SceneIo>()
                    .set_status("Operation cancelled");
                return;
            }
        }
    }

    let status = match request {
        SceneIoRequest::New => new_scene_tab(world),
        SceneIoRequest::Save => save_active_or_prompt(world),
        SceneIoRequest::SaveAs => save_with_dialog(world),
        SceneIoRequest::Open => open_with_dialog(world),
        SceneIoRequest::ImportAssets => import_with_dialog(world),
        SceneIoRequest::ExportSelection => export_selection_with_dialog(world),
        SceneIoRequest::SaveSelectionAsPrefab => save_selection_as_prefab(world),
        SceneIoRequest::SwitchProject => switch_project(world),
        SceneIoRequest::RestoreRecovery => restore_recovery(world),
        SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world),
        SceneIoRequest::DiscardRecovery => discard_recovery(world),
        SceneIoRequest::OpenRecent(index) => open_recent(world, index),
        SceneIoRequest::OpenPath(path) => open_path(world, path),
        SceneIoRequest::SwitchTab(index) => switch_scene_tab(world, index),
        SceneIoRequest::CloseTab(index) => close_scene_tab(world, index),
        SceneIoRequest::ReloadComposition => reload_active_composition(world),
    };

    world.resource_mut::<SceneIo>().set_status(status);
}

enum DirtyConfirm {
    Save,
    Discard,
    Cancel,
}

fn needs_dirty_confirm(request: &SceneIoRequest) -> bool {
    matches!(request, SceneIoRequest::SwitchProject)
}

fn switch_project(world: &mut World) -> String {
    match crate::launcher::spawn_project_launcher_process() {
        Ok(()) => {
            world.write_message(AppExit::Success);
            "Opening Blacksite Project Browser".to_string()
        }
        Err(error) => format!("Switch project failed: {error}"),
    }
}

fn confirm_discard_or_save() -> DirtyConfirm {
    use rfd::{MessageButtons, MessageDialog, MessageLevel};
    match MessageDialog::new()
        .set_title("Unsaved Changes")
        .set_description("The scene has unsaved changes. Save before continuing?")
        .set_level(MessageLevel::Warning)
        .set_buttons(MessageButtons::YesNoCancel)
        .show()
    {
        rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
        rfd::MessageDialogResult::No => DirtyConfirm::Discard,
        _ => DirtyConfirm::Cancel,
    }
}

fn open_recent(world: &mut World, index: usize) -> String {
    let path = world.resource::<SceneIo>().recent_paths.get(index).cloned();
    let Some(path) = path else {
        return "Recent scene not found".to_string();
    };
    open_path(world, path)
}

fn open_path(world: &mut World, path: PathBuf) -> String {
    if let Some(index) = world
        .resource::<SceneIo>()
        .tabs
        .iter()
        .position(|tab| tab.path.as_ref() == Some(&path))
    {
        return switch_scene_tab(world, index);
    }
    if let Err(error) = capture_active_tab(world) {
        return format!("Open failed: could not preserve active scene: {error}");
    }
    match load_level(world, &path) {
        Ok(disk_snapshot) => {
            let id = world.resource_mut::<SceneIo>().allocate_tab_id();
            {
                let mut io = world.resource_mut::<SceneIo>();
                io.tabs.push(SceneTab {
                    id,
                    path: Some(path.clone()),
                    dirty: false,
                    snapshot: String::new(),
                    recovery_snapshot: None,
                    disk_snapshot: Some(disk_snapshot),
                });
                io.active_tab = io.tabs.len() - 1;
                io.active_path = Some(path.clone());
                io.recovery_snapshot = None;
                io.mark_clean();
            }
            remember_path(world, path.clone());
            world.resource_mut::<EditorHistory>().clear();
            refresh_recovery_notice(world, &path);
            if let Err(error) = capture_active_tab(world) {
                warn!("Failed to capture opened scene tab: {error}");
            }
            format!("Loading {}", path.display())
        }
        Err(err) => format!("Open failed: {err}"),
    }
}

fn new_scene_tab(world: &mut World) -> String {
    if let Err(error) = capture_active_tab(world) {
        return format!("New scene failed: could not preserve active scene: {error}");
    }
    clear_scene_world(world);
    world.insert_resource(SceneComposition {
        scene_id: uuid::Uuid::new_v4().to_string(),
        subscenes: Vec::new(),
    });
    let id = world.resource_mut::<SceneIo>().allocate_tab_id();
    {
        let mut io = world.resource_mut::<SceneIo>();
        io.tabs.push(SceneTab {
            id,
            path: None,
            dirty: false,
            snapshot: String::new(),
            recovery_snapshot: None,
            disk_snapshot: None,
        });
        io.active_tab = io.tabs.len() - 1;
        io.active_path = None;
        io.recovery_snapshot = None;
        io.mark_clean();
    }
    world.resource_mut::<EditorHistory>().clear();
    if let Err(error) = capture_active_tab(world) {
        return format!("New scene failed: {error}");
    }
    "Created a new empty scene tab".to_string()
}

fn capture_active_tab(world: &mut World) -> Result<(), String> {
    if world.resource::<SceneIo>().tabs.is_empty() {
        return Ok(());
    }
    ensure_scene_composition_identity(world);
    let text = serialize_active_scene(world)?;
    let (active_tab, active_path, dirty, recovery_snapshot) = {
        let io = world.resource::<SceneIo>();
        (
            io.active_tab,
            io.active_path.clone(),
            io.dirty,
            io.recovery_snapshot.clone(),
        )
    };
    let mut io = world.resource_mut::<SceneIo>();
    let Some(tab) = io.tabs.get_mut(active_tab) else {
        return Err(format!("active scene tab {active_tab} is missing"));
    };
    tab.path = active_path;
    tab.dirty = dirty;
    tab.snapshot = text;
    tab.recovery_snapshot = recovery_snapshot;
    Ok(())
}

fn switch_scene_tab(world: &mut World, index: usize) -> String {
    let current = world.resource::<SceneIo>().active_tab;
    if index == current {
        return "Scene tab is already active".to_string();
    }
    if index >= world.resource::<SceneIo>().tabs.len() {
        return "Scene tab not found".to_string();
    }
    if let Err(error) = capture_active_tab(world) {
        return format!("Scene switch failed: could not preserve active scene: {error}");
    }
    let target = world.resource::<SceneIo>().tabs[index].clone();
    if target.snapshot.is_empty() {
        return "Scene switch failed: target tab has no recoverable snapshot".to_string();
    }
    if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
        return format!("Scene switch failed: {error}");
    }
    {
        let mut io = world.resource_mut::<SceneIo>();
        io.active_tab = index;
        io.active_path = target.path.clone();
        io.dirty = target.dirty;
        io.recovery_snapshot = target.recovery_snapshot.clone();
        io.sync_active_tab_metadata();
    }
    world.resource_mut::<EditorHistory>().clear();
    format!("Activated scene tab {}", target.label())
}

fn close_scene_tab(world: &mut World, index: usize) -> String {
    if index >= world.resource::<SceneIo>().tabs.len() {
        return "Scene tab not found".to_string();
    }
    if index != world.resource::<SceneIo>().active_tab {
        let status = switch_scene_tab(world, index);
        if status.starts_with("Scene switch failed") {
            return status;
        }
    }

    if world.resource::<SceneIo>().dirty {
        match confirm_close_scene() {
            DirtyConfirm::Save => {
                let status = save_active_or_prompt(world);
                if status.starts_with("Save failed") || status.ends_with("cancelled") {
                    return status;
                }
            }
            DirtyConfirm::Discard => {}
            DirtyConfirm::Cancel => return "Close scene cancelled".to_string(),
        }
    }

    if world.resource::<SceneIo>().tabs.len() == 1 {
        clear_scene_world(world);
        world.insert_resource(SceneComposition {
            scene_id: uuid::Uuid::new_v4().to_string(),
            subscenes: Vec::new(),
        });
        {
            let mut io = world.resource_mut::<SceneIo>();
            let id = io.allocate_tab_id();
            io.tabs = vec![SceneTab {
                id,
                path: None,
                dirty: false,
                snapshot: String::new(),
                recovery_snapshot: None,
                disk_snapshot: None,
            }];
            io.active_tab = 0;
            io.active_path = None;
            io.recovery_snapshot = None;
            io.mark_clean();
        }
        world.resource_mut::<EditorHistory>().clear();
        let _ = capture_active_tab(world);
        return "Closed scene; created an empty scene tab".to_string();
    }

    let closing = world.resource::<SceneIo>().active_tab;
    let target_index = if closing + 1 < world.resource::<SceneIo>().tabs.len() {
        closing + 1
    } else {
        closing - 1
    };
    let target = world.resource::<SceneIo>().tabs[target_index].clone();
    if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
        return format!("Close scene failed: {error}");
    }
    {
        let mut io = world.resource_mut::<SceneIo>();
        io.tabs.remove(closing);
        io.active_tab = if target_index > closing {
            target_index - 1
        } else {
            target_index
        };
        io.active_path = target.path.clone();
        io.dirty = target.dirty;
        io.recovery_snapshot = target.recovery_snapshot;
        io.sync_active_tab_metadata();
    }
    world.resource_mut::<EditorHistory>().clear();
    "Closed scene tab".to_string()
}

fn reload_active_composition(world: &mut World) -> String {
    if let Err(error) = capture_active_tab(world) {
        return format!("Composition reload failed: {error}");
    }
    let target = world.resource::<SceneIo>().tabs[world.resource::<SceneIo>().active_tab].clone();
    if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
        return format!("Composition reload failed: {error}");
    }
    {
        let mut io = world.resource_mut::<SceneIo>();
        io.active_path = target.path;
        io.dirty = target.dirty;
        io.recovery_snapshot = target.recovery_snapshot;
        io.sync_active_tab_metadata();
    }
    world.resource_mut::<EditorHistory>().clear();
    "Reloaded active scene composition".to_string()
}

fn confirm_close_scene() -> DirtyConfirm {
    use rfd::{MessageButtons, MessageDialog, MessageLevel};
    match MessageDialog::new()
        .set_title("Close Scene")
        .set_description("This scene tab has unsaved changes. Save before closing it?")
        .set_level(MessageLevel::Warning)
        .set_buttons(MessageButtons::YesNoCancel)
        .show()
    {
        rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
        rfd::MessageDialogResult::No => DirtyConfirm::Discard,
        _ => DirtyConfirm::Cancel,
    }
}

fn save_all_tabs(world: &mut World) -> String {
    let original_id = world.resource::<SceneIo>().tabs[world.resource::<SceneIo>().active_tab].id;
    let dirty_ids: Vec<u64> = world
        .resource::<SceneIo>()
        .tabs
        .iter()
        .filter(|tab| tab.dirty)
        .map(|tab| tab.id)
        .collect();
    for id in dirty_ids {
        let Some(index) = world
            .resource::<SceneIo>()
            .tabs
            .iter()
            .position(|tab| tab.id == id)
        else {
            continue;
        };
        if index != world.resource::<SceneIo>().active_tab {
            let status = switch_scene_tab(world, index);
            if status.starts_with("Scene switch failed") {
                return format!("Save failed: {status}");
            }
        }
        let status = save_active_or_prompt(world);
        if status.starts_with("Save failed") || status.ends_with("cancelled") {
            return status;
        }
    }
    if let Some(index) = world
        .resource::<SceneIo>()
        .tabs
        .iter()
        .position(|tab| tab.id == original_id)
    {
        if index != world.resource::<SceneIo>().active_tab {
            let _ = switch_scene_tab(world, index);
        }
    }
    "Saved all modified scene tabs".to_string()
}

fn save_selection_as_prefab(world: &mut World) -> String {
    let selected_roots: Vec<Entity> = world
        .get_resource::<UiState>()
        .map(|ui| {
            ui.selected_entities
                .iter()
                .filter(|entity| {
                    world
                        .get_entity(*entity)
                        .is_ok_and(|e| e.contains::<LevelObject>())
                })
                .collect()
        })
        .unwrap_or_default();
    let selection = expand_authored_selection(world, &selected_roots);
    if selection.is_empty() {
        return "Select level objects to save as prefab".to_string();
    }

    let Some(path) = rfd::FileDialog::new()
        .set_directory("assets/prefabs")
        .add_filter("Bevy prefab", &["scn.ron", "ron"])
        .set_file_name("prefab.scn.ron")
        .save_file()
    else {
        return "Save prefab cancelled".to_string();
    };
    let expected = match FileSnapshot::capture(&path) {
        Ok(expected) => expected,
        Err(error) => return format!("Save prefab failed: {error}"),
    };

    if ensure_unique_actor_ids(world, &selection) > 0 {
        world.resource_mut::<SceneIo>().mark_dirty();
    }

    match save_prefab_entities(
        world,
        &path,
        selection,
        SceneWriteContext::Standalone {
            expected,
            description: "prefab copy",
        },
    ) {
        Ok(count) => {
            world.resource_mut::<EditorAssets>().refresh();
            format!(
                "Saved prefab with {count} root entity(s) to {}",
                path.display()
            )
        }
        Err(err) => format!("Save prefab failed: {err}"),
    }
}

fn ensure_unique_actor_ids(world: &mut World, entities: &[Entity]) -> usize {
    let selected: HashSet<Entity> = entities.iter().copied().collect();
    let mut occupied: HashSet<String> = world
        .query_filtered::<(
            Entity,
            &ActorId,
            Has<HydratedPrefabMember>,
            Has<ComposedSceneMember>,
        ), With<LevelObject>>()
        .iter(world)
        .filter(|(entity, id, hydrated, composed)| {
            !selected.contains(entity) && !id.0.trim().is_empty() && !hydrated && !composed
        })
        .map(|(_, id, _, _)| id.0.clone())
        .collect();
    let mut changed = 0;
    for entity in entities {
        let current = world
            .get::<ActorId>(*entity)
            .map(|id| id.0.trim().to_string())
            .filter(|id| !id.is_empty());
        if current
            .as_ref()
            .is_some_and(|actor_id| occupied.insert(actor_id.clone()))
        {
            continue;
        }
        let actor_id = loop {
            let candidate = uuid::Uuid::new_v4().to_string();
            if occupied.insert(candidate.clone()) {
                break candidate;
            }
        };
        if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
            entity_mut.insert(ActorId::new(actor_id));
            changed += 1;
        }
    }
    changed
}

fn expand_authored_selection(world: &World, roots: &[Entity]) -> Vec<Entity> {
    let mut selected = HashSet::new();
    let mut stack = roots.to_vec();
    while let Some(entity) = stack.pop() {
        if !world
            .get_entity(entity)
            .is_ok_and(|entity_ref| entity_ref.contains::<LevelObject>())
            || world.get::<HydratedPrefabMember>(entity).is_some()
            || world.get::<ComposedSceneMember>(entity).is_some()
            || !selected.insert(entity)
        {
            continue;
        }
        if let Some(children) = world.get::<Children>(entity) {
            stack.extend(children.iter());
        }
    }
    let mut selected: Vec<_> = selected.into_iter().collect();
    selected.sort_by_key(|entity| entity.to_bits());
    selected
}

fn save_prefab_entities(
    world: &mut World,
    path: &Path,
    entities: Vec<Entity>,
    write_context: SceneWriteContext,
) -> Result<usize, String> {
    let count = entities.len();
    let original_actor_ids: Vec<_> = entities
        .iter()
        .map(|entity| (*entity, world.get::<ActorId>(*entity).cloned()))
        .collect();
    let repaired = ensure_unique_actor_ids(world, &entities);
    let result = (|| {
        let text = serialize_standalone_entities(world, entities)?;
        let project_root = PathBuf::from(
            world
                .resource::<crate::project_io::ProjectWorkspace>()
                .root
                .clone(),
        );
        scene::validate_prefab_graph_text(&text, path, &project_root)?;
        publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
        Ok(count)
    })();
    if result.is_err() {
        for (entity, actor_id) in original_actor_ids {
            if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
                match actor_id {
                    Some(actor_id) => {
                        entity_mut.insert(actor_id);
                    }
                    None => {
                        entity_mut.remove::<ActorId>();
                    }
                }
            }
        }
    } else if repaired > 0 {
        world.resource_mut::<SceneIo>().mark_dirty();
    }
    result
}

/// Save helper for play-mode and other cross-module callers.
pub fn save_active_or_prompt_world(world: &mut World) -> String {
    save_active_or_prompt(world)
}

fn save_active_or_prompt(world: &mut World) -> String {
    let path = world.resource::<SceneIo>().active_path.clone();
    match path {
        Some(path) => match save_level(world, &path, SceneWriteContext::Active) {
            Ok(count) => {
                world.resource_mut::<SceneIo>().mark_clean();
                remember_path(world, path.clone());
                retire_scene_recovery(world, &path);
                format!("Saved {count} level entities to {}", path.display())
            }
            Err(err) => format!("Save failed: {err}"),
        },
        None => save_with_dialog(world),
    }
}

fn save_with_dialog(world: &mut World) -> String {
    let Some(path) = rfd::FileDialog::new()
        .set_directory("assets/levels")
        .add_filter("Bevy scene", &["scn.ron", "ron"])
        .set_file_name("editor_scene.scn.ron")
        .save_file()
    else {
        return "Save cancelled".to_string();
    };
    let expected = match FileSnapshot::capture(&path) {
        Ok(expected) => expected,
        Err(error) => return format!("Save failed: {error}"),
    };

    match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) {
        Ok(count) => {
            world.resource_mut::<SceneIo>().active_path = Some(path.clone());
            world.resource_mut::<SceneIo>().mark_clean();
            remember_path(world, path.clone());
            retire_scene_recovery(world, &path);
            format!("Saved {count} level entities to {}", path.display())
        }
        Err(err) => format!("Save failed: {err}"),
    }
}

fn open_with_dialog(world: &mut World) -> String {
    let Some(path) = rfd::FileDialog::new()
        .set_directory("assets/levels")
        .add_filter("Bevy scene", &["scn.ron", "ron"])
        .pick_file()
    else {
        return "Open cancelled".to_string();
    };

    open_path(world, path)
}

fn import_with_dialog(world: &mut World) -> String {
    let Some(paths) = rfd::FileDialog::new()
        .add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS)
        .pick_files()
    else {
        return "Import cancelled".to_string();
    };

    match import_external_assets(&paths) {
        Ok(count) => {
            world.resource_mut::<EditorAssets>().refresh();
            format!("Imported {count} asset(s)")
        }
        Err(err) => format!("Import failed: {err}"),
    }
}

fn export_selection_with_dialog(world: &mut World) -> String {
    let Some(entity) = world.resource::<SelectedEntity>().0 else {
        return "Nothing selected to export".to_string();
    };
    if snapshot_entity(world, entity).is_none() {
        return "Selected entity is not an authored level object".to_string();
    }

    let Some(path) = rfd::FileDialog::new()
        .set_directory("assets/levels")
        .add_filter("Bevy prefab", &["scn.ron", "ron"])
        .set_file_name("selection.scn.ron")
        .save_file()
    else {
        return "Export cancelled".to_string();
    };
    let expected = match FileSnapshot::capture(&path) {
        Ok(expected) => expected,
        Err(error) => return format!("Export failed: {error}"),
    };

    match save_standalone_entities(
        world,
        &path,
        vec![entity],
        SceneWriteContext::Standalone {
            expected,
            description: "selection export",
        },
    ) {
        Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
        Err(err) => format!("Export failed: {err}"),
    }
}

fn clear_loaded_scene_roots(world: &mut World) {
    let mut query = world.query_filtered::<Entity, With<LoadedSceneRoot>>();
    let entities: Vec<Entity> = query.iter(world).collect();
    for entity in entities {
        if let Ok(entity_mut) = world.get_entity_mut(entity) {
            entity_mut.despawn();
        }
    }
}

fn clear_scene_world(world: &mut World) {
    clear_loaded_scene_roots(world);
    clear_level_objects(world);
    if let Some(mut hierarchy) =
        world.get_resource_mut::<crate::ui::hierarchy_state::HierarchyPanelState>()
    {
        hierarchy.locked.clear();
    }
    if let Some(mut ui) = world.get_resource_mut::<UiState>() {
        ui.selected_entities.clear();
    }
    if let Some(mut selected) = world.get_resource_mut::<SelectedEntity>() {
        selected.0 = None;
    }
}

fn save_level(
    world: &mut World,
    path: &Path,
    write_context: SceneWriteContext,
) -> Result<usize, String> {
    let entities = authored_scene_entities(world);
    if is_prefab_document(path) {
        save_prefab_entities(world, path, entities, write_context)
    } else {
        save_entities(world, path, entities, write_context)
    }
}

pub(crate) fn serialize_active_scene(world: &mut World) -> Result<String, String> {
    let entities = authored_scene_entities(world);
    if world
        .resource::<SceneIo>()
        .active_path
        .as_deref()
        .is_some_and(is_prefab_document)
    {
        serialize_standalone_entities(world, entities)
    } else {
        serialize_entities(world, entities)
    }
}

pub(crate) fn is_prefab_document(path: &Path) -> bool {
    let normalized = path.to_string_lossy().replace('\\', "/");
    normalized.starts_with("assets/prefabs/") || normalized.contains("/assets/prefabs/")
}

fn authored_scene_entities(world: &mut World) -> Vec<Entity> {
    let mut query = world.query_filtered::<Entity, (
        With<LevelObject>,
        Without<ComposedSceneMember>,
        Without<HydratedPrefabMember>,
    )>();
    query.iter(world).collect()
}

fn format_actor_validation(err: ActorValidationError) -> String {
    match err {
        ActorValidationError::MissingActorKind => {
            "Save failed: level object missing ActorKind (re-open scene to migrate)".into()
        }
        ActorValidationError::MissingTransform => {
            "Save failed: level object missing Transform".into()
        }
        ActorValidationError::BrushMissingDesc => {
            "Save failed: Brush actor requires BrushDesc".into()
        }
        ActorValidationError::BrushHasPrimitive => {
            "Save failed: Brush actor cannot have Primitive".into()
        }
        ActorValidationError::BrushHasStaticMeshRenderer => {
            "Save failed: Brush actor cannot have StaticMeshRenderer".into()
        }
        ActorValidationError::BrushHasLight => {
            "Save failed: Brush actor cannot have LightDesc".into()
        }
        ActorValidationError::BrushHasModelRef => {
            "Save failed: Brush actor cannot have ModelRef".into()
        }
        ActorValidationError::InvalidBrushGeometry(message) => {
            format!("Save failed: invalid brush geometry: {message}")
        }
        ActorValidationError::StaticMeshMissingPrimitive => {
            "Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
                .into()
        }
        ActorValidationError::StaticMeshHasLight => {
            "Save failed: StaticMesh actor cannot have LightDesc".into()
        }
        ActorValidationError::StaticMeshHasModelRef => {
            "Save failed: StaticMesh actor cannot have ModelRef".into()
        }
        ActorValidationError::ImportedModelMissingModelRef => {
            "Save failed: ImportedModel actor requires ModelRef".into()
        }
        ActorValidationError::ImportedModelHasPrimitive => {
            "Save failed: ImportedModel actor cannot have Primitive".into()
        }
        ActorValidationError::ImportedModelHasStaticMeshRenderer => {
            "Save failed: ImportedModel actor cannot have StaticMeshRenderer".into()
        }
        ActorValidationError::SkinnedMeshMissingRenderer => {
            "Save failed: SkinnedMesh actor requires SkinnedMeshRenderer".into()
        }
        ActorValidationError::SkinnedMeshInvalidRenderer => {
            "Save failed: SkinnedMeshRenderer requires a model source path".into()
        }
        ActorValidationError::SkinnedMeshHasPrimitive => {
            "Save failed: SkinnedMesh actor cannot have Primitive".into()
        }
        ActorValidationError::SkinnedMeshHasStaticMeshRenderer => {
            "Save failed: SkinnedMesh actor cannot have StaticMeshRenderer".into()
        }
        ActorValidationError::SkinnedMeshHasModelRef => {
            "Save failed: SkinnedMesh actor cannot have ModelRef".into()
        }
        ActorValidationError::SkinnedMeshRendererActorKindMismatch => {
            "Save failed: SkinnedMeshRenderer requires ActorKind::SkinnedMesh".into()
        }
        ActorValidationError::ConflictingGeometrySources => {
            "Save failed: actor has more than one primary geometry source component".into()
        }
        ActorValidationError::LightMissingLightDesc => {
            "Save failed: Light actor requires LightDesc".into()
        }
        ActorValidationError::LightHasPrimitive => {
            "Save failed: Light actor cannot have Primitive".into()
        }
        ActorValidationError::LightHasModelRef => {
            "Save failed: Light actor cannot have ModelRef".into()
        }
        ActorValidationError::LightHasStaticMeshRenderer => {
            "Save failed: Light actor cannot have StaticMeshRenderer".into()
        }
        ActorValidationError::AudioSourceMissingDesc => {
            "Save failed: AudioSource actor requires AudioSourceDesc".into()
        }
        ActorValidationError::AudioSourceMissingClip => {
            "Save failed: audio source requires an assigned clip".into()
        }
        ActorValidationError::AudioSourceInvalidClipReference => {
            "Save failed: audio source clip reference is unresolved or not an audio clip".into()
        }
        ActorValidationError::AudioSourceInvalidGain => {
            "Save failed: audio source gain is outside the supported dB range".into()
        }
        ActorValidationError::AudioSourceInvalidPitch => {
            "Save failed: audio source pitch must be positive and finite".into()
        }
        ActorValidationError::AudioSourceInvalidSpatialBlend => {
            "Save failed: audio source spatial blend must be between 0 and 1".into()
        }
        ActorValidationError::AudioSourceInvalidAttenuation => {
            "Save failed: audio source attenuation distances or rolloff are invalid".into()
        }
        ActorValidationError::AudioSourceMissingBus => {
            "Save failed: audio source requires a bus ID".into()
        }
        ActorValidationError::AudioListenerMissingDesc => {
            "Save failed: AudioListener actor requires AudioListenerDesc".into()
        }
        ActorValidationError::AudioListenerInvalidEarGap => {
            "Save failed: audio listener ear gap must be positive and finite".into()
        }
        ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer => {
            "Save failed: animation controller requires SkinnedMeshRenderer on the same actor"
                .into()
        }
        ActorValidationError::AnimationControllerMissingSkeleton => {
            "Save failed: animation controller requires an assigned skeleton".into()
        }
        ActorValidationError::AnimationControllerInvalidSkeletonReference => {
            "Save failed: animation controller skeleton reference is unresolved or invalid".into()
        }
        ActorValidationError::AnimationControllerEmptyStateId => {
            "Save failed: animation controller state IDs cannot be empty".into()
        }
        ActorValidationError::AnimationControllerDuplicateStateId => {
            "Save failed: animation controller state IDs must be unique".into()
        }
        ActorValidationError::AnimationControllerInvalidClipReference => {
            "Save failed: animation states require resolved glTF/GLB clip references".into()
        }
        ActorValidationError::AnimationControllerInvalidStateSpeed => {
            "Save failed: animation state speed must be finite and nonzero".into()
        }
        ActorValidationError::AnimationControllerInvalidStateRange => {
            "Save failed: animation state playback range is invalid".into()
        }
        ActorValidationError::AnimationControllerInvalidCrossfade => {
            "Save failed: animation controller crossfade must be finite and non-negative".into()
        }
        ActorValidationError::AnimationControllerMissingDefaultState => {
            "Save failed: animation controller requires a default state".into()
        }
        ActorValidationError::AnimationControllerUnknownDefaultState => {
            "Save failed: animation controller default state does not exist".into()
        }
        ActorValidationError::PostProcessVolumeMissingDesc => {
            "Save failed: PostProcessVolume actor requires PostProcessVolumeDesc".into()
        }
        ActorValidationError::PostProcessVolumeInvalidHalfExtents => {
            "Save failed: post-process volume half_extents must be positive".into()
        }
        ActorValidationError::PostProcessVolumeInvalidBlendDistance => {
            "Save failed: post-process volume blend_distance must be non-negative".into()
        }
        ActorValidationError::PostProcessVolumeInvalidOverrideScalar => {
            "Save failed: post-process volume override contains invalid scalar".into()
        }
        ActorValidationError::InvalidNavigation(message) => {
            format!("Save failed: invalid navigation authoring: {message}")
        }
    }
}

fn save_entities(
    world: &mut World,
    path: &Path,
    entities: Vec<Entity>,
    write_context: SceneWriteContext,
) -> Result<usize, String> {
    let count = entities.len();
    let text = serialize_entities(world, entities)?;
    publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
    Ok(count)
}

fn save_standalone_entities(
    world: &mut World,
    path: &Path,
    entities: Vec<Entity>,
    write_context: SceneWriteContext,
) -> Result<usize, String> {
    let count = entities.len();
    let text = serialize_standalone_entities(world, entities)?;
    publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
    Ok(count)
}

#[derive(Clone)]
enum SceneWriteContext {
    Active,
    ActiveSaveAs {
        expected: FileSnapshot,
    },
    Standalone {
        expected: FileSnapshot,
        description: &'static str,
    },
}

fn publish_scene_text(
    world: &mut World,
    path: &Path,
    bytes: &[u8],
    entity_count: usize,
    context: SceneWriteContext,
) -> Result<(), String> {
    match context {
        SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => {
            let (tab_id, expected) = match context {
                SceneWriteContext::Active => active_scene_write_baseline(world, path)?,
                SceneWriteContext::ActiveSaveAs { expected } => {
                    (active_scene_tab_id(world)?, expected)
                }
                SceneWriteContext::Standalone { .. } => unreachable!(),
            };
            let snapshot = publish_authored_file(
                world,
                path,
                bytes,
                &expected,
                FileWriteIntent::Scene {
                    tab_id,
                    entity_count,
                },
            )?;
            let mut io = world.resource_mut::<SceneIo>();
            let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else {
                return Err("saved scene tab no longer exists".into());
            };
            tab.disk_snapshot = Some(snapshot);
        }
        SceneWriteContext::Standalone {
            expected,
            description,
        } => {
            publish_authored_file(
                world,
                path,
                bytes,
                &expected,
                FileWriteIntent::Standalone {
                    description: description.into(),
                },
            )?;
        }
    }
    Ok(())
}

fn active_scene_write_baseline(
    world: &World,
    destination: &Path,
) -> Result<(u64, FileSnapshot), String> {
    let io = world.resource::<SceneIo>();
    let tab = io
        .tabs
        .get(io.active_tab)
        .ok_or_else(|| "active scene tab is missing".to_string())?;
    if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) {
        return Err(format!(
            "{} is not the active scene destination; use Save As",
            destination.display()
        ));
    }
    let expected = tab.disk_snapshot.clone().ok_or_else(|| {
        format!(
            "scene disk baseline is unavailable for {}; reload it or use Save As",
            destination.display()
        )
    })?;
    Ok((tab.id, expected))
}

fn active_scene_tab_id(world: &World) -> Result<u64, String> {
    let io = world.resource::<SceneIo>();
    io.tabs
        .get(io.active_tab)
        .map(|tab| tab.id)
        .ok_or_else(|| "active scene tab is missing".to_string())
}

fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
    serialize_entities_inner_with_resources(world, entities, true)
}

#[cfg(test)]
fn serialize_entities_inner(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
    serialize_entities_inner_with_resources(world, entities, true)
}

fn serialize_standalone_entities(
    world: &mut World,
    entities: Vec<Entity>,
) -> Result<String, String> {
    serialize_entities_inner_with_resources(world, entities, false)
}

fn serialize_entities_inner_with_resources(
    world: &mut World,
    entities: Vec<Entity>,
    include_scene_resources: bool,
) -> Result<String, String> {
    for entity in &entities {
        let entity_ref = world.entity(*entity);
        if let Err(err) = validate_actor(entity_ref) {
            return Err(format_actor_validation(err));
        }
    }

    let entity_set: HashSet<Entity> = entities.iter().copied().collect();
    let detached_parents: Vec<(Entity, Entity, Transform)> = if include_scene_resources {
        Vec::new()
    } else {
        entities
            .iter()
            .filter_map(|entity| {
                world
                    .get::<ChildOf>(*entity)
                    .map(ChildOf::parent)
                    .filter(|parent| !entity_set.contains(parent))
                    .and_then(|parent| {
                        computed_world_transform(world, *entity)
                            .map(|world_transform| (*entity, parent, world_transform))
                    })
            })
            .collect()
    };
    let original_local_transforms: Vec<(Entity, Transform)> = detached_parents
        .iter()
        .filter_map(|(entity, _, _)| {
            world
                .get::<Transform>(*entity)
                .copied()
                .map(|transform| (*entity, transform))
        })
        .collect();
    for (entity, _, world_transform) in &detached_parents {
        world
            .entity_mut(*entity)
            .remove::<ChildOf>()
            .insert(*world_transform);
    }

    let result = (|| {
        let ron = {
            let registry = world.resource::<AppTypeRegistry>().read();
            let mut component_filter = WorldFilter::deny_all()
                .allow::<Name>()
                .allow::<Transform>()
                .allow::<ChildOf>()
                .allow::<LevelObject>()
                .allow::<ActorId>()
                .allow::<ActorName>()
                .allow::<HierarchySiblingIndex>()
                .allow::<EditorVisibility>();
            let component_registry = world
                .get_resource::<crate::ui::component_registry::EditorComponentRegistry>()
                .cloned()
                .unwrap_or_default();
            for descriptor in &component_registry.descriptors {
                if let Some(registration) = registry.get_with_type_path(descriptor.type_name) {
                    component_filter = component_filter.allow_by_id(registration.type_id());
                }
            }
            let builder = DynamicWorldBuilder::from_world(world, &registry)
                .with_component_filter(component_filter)
                .extract_entities(entities.into_iter());
            let scene = if include_scene_resources {
                builder
                    .allow_resource::<SceneComposition>()
                    .extract_resources()
                    .remove_empty_entities()
                    .build()
            } else {
                builder.remove_empty_entities().build()
            };

            scene
                .serialize(&registry)
                .map_err(|err| format!("could not serialize scene: {err}"))?
        };

        let document = SceneDocument::from_ron_text(&ron)?;
        document.to_ron_text()
    })();
    for (entity, transform) in original_local_transforms {
        if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
            entity_mut.insert(transform);
        }
    }
    for (entity, parent, _) in detached_parents {
        if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
            entity_mut.insert(ChildOf(parent));
        }
    }
    result
}

fn computed_world_transform(world: &World, entity: Entity) -> Option<Transform> {
    let mut chain = Vec::new();
    let mut current = entity;
    let mut visited = HashSet::new();
    while visited.insert(current) {
        chain.push(*world.get::<Transform>(current)?);
        let Some(parent) = world.get::<ChildOf>(current).map(ChildOf::parent) else {
            break;
        };
        current = parent;
    }
    if world.get::<ChildOf>(current).is_some() {
        return None;
    }
    let mut chain = chain.into_iter().rev();
    let root = chain.next()?;
    Some(
        chain
            .fold(GlobalTransform::from(root), |global, local| {
                global.mul_transform(local)
            })
            .compute_transform(),
    )
}

fn tick_scene_recovery(world: &mut World) {
    let (enabled, interval_secs, max_generations) = {
        let prefs = world.resource::<crate::project_io::UserPreferences>();
        (
            prefs.scene_autosave_enabled,
            recovery_interval_secs(prefs),
            prefs.scene_recovery_generations.max(1),
        )
    };
    if !enabled {
        return;
    }

    let dirty = world.resource::<SceneIo>().has_unsaved_tabs();
    let delta = world.resource::<Time>().delta_secs();
    let should_snapshot = {
        let mut clock = world.resource_mut::<RecoveryClock>();
        if !dirty {
            clock.elapsed_secs = 0.0;
            return;
        }
        clock.elapsed_secs += delta;
        if clock.elapsed_secs < interval_secs {
            false
        } else {
            clock.elapsed_secs = 0.0;
            true
        }
    };
    if !should_snapshot {
        return;
    }

    let status = match save_dirty_tab_recoveries(world, max_generations) {
        Ok(0) => "Recovery skipped: modified tabs are unsaved".to_string(),
        Ok(1) => "Recovery snapshot saved for 1 scene tab".to_string(),
        Ok(count) => format!("Recovery snapshots saved for {count} scene tabs"),
        Err(error) => format!("Recovery snapshot failed: {error}"),
    };
    world.resource_mut::<SceneIo>().set_status(status);
}

fn recovery_interval_secs(prefs: &crate::project_io::UserPreferences) -> f32 {
    std::env::var("BLACKSITE_RECOVERY_INTERVAL_SECS")
        .ok()
        .and_then(|value| value.parse::<f32>().ok())
        .filter(|value| value.is_finite() && *value >= 1.0)
        .unwrap_or_else(|| prefs.scene_autosave_interval_secs.max(15) as f32)
}

fn save_dirty_tab_recoveries(world: &mut World, max_generations: usize) -> Result<usize, String> {
    capture_active_tab(world)?;
    let state_root =
        default_state_root().ok_or_else(|| "HOME and XDG_STATE_HOME are unset".to_string())?;
    let project_root = PathBuf::from(
        world
            .resource::<crate::project_io::ProjectWorkspace>()
            .root
            .clone(),
    );
    let candidates = dirty_saved_tab_snapshots(world.resource::<SceneIo>());
    let mut saved = Vec::with_capacity(candidates.len());
    for (index, path, snapshot) in candidates {
        let recovery = write_recovery_snapshot(
            &state_root,
            &project_root,
            &path,
            snapshot.as_bytes(),
            max_generations,
        )?;
        saved.push((index, recovery));
    }
    let count = saved.len();
    let mut io = world.resource_mut::<SceneIo>();
    for (index, recovery) in saved {
        if let Some(tab) = io.tabs.get_mut(index) {
            tab.recovery_snapshot = Some(recovery.clone());
        }
        if io.active_tab == index {
            io.recovery_snapshot = Some(recovery);
        }
    }
    Ok(count)
}

fn dirty_saved_tab_snapshots(io: &SceneIo) -> Vec<(usize, PathBuf, String)> {
    io.tabs
        .iter()
        .enumerate()
        .filter_map(|(index, tab)| {
            (tab.dirty && !tab.snapshot.is_empty())
                .then(|| {
                    tab.path
                        .clone()
                        .map(|path| (index, path, tab.snapshot.clone()))
                })
                .flatten()
        })
        .collect()
}

fn refresh_recovery_notice(world: &mut World, scene_path: &Path) {
    let recovery = default_state_root().and_then(|state_root| {
        let project_root = PathBuf::from(
            world
                .resource::<crate::project_io::ProjectWorkspace>()
                .root
                .clone(),
        );
        latest_recovery_snapshot(&state_root, &project_root, scene_path)
    });
    world.resource_mut::<SceneIo>().recovery_snapshot = recovery;
}

fn retire_scene_recovery(world: &mut World, scene_path: &Path) {
    let Some(state_root) = default_state_root() else {
        return;
    };
    let project_root = PathBuf::from(
        world
            .resource::<crate::project_io::ProjectWorkspace>()
            .root
            .clone(),
    );
    if let Err(error) = discard_recovery_snapshots(&state_root, &project_root, scene_path) {
        warn!("Failed to retire scene recovery snapshots: {error}");
    }
    world.resource_mut::<SceneIo>().recovery_snapshot = None;
}

fn restore_recovery(world: &mut World) -> String {
    let (active_path, snapshot) = {
        let io = world.resource::<SceneIo>();
        (io.active_path.clone(), io.recovery_snapshot.clone())
    };
    let Some(snapshot) = snapshot else {
        return "No scene recovery snapshot is available".to_string();
    };
    match load_level(world, &snapshot) {
        Ok(_) => {
            mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
            world.resource_mut::<EditorHistory>().clear();
            format!(
                "Restored recovery {}; save the scene to keep it or discard the snapshot",
                snapshot.display()
            )
        }
        Err(error) => format!("Recovery restore failed: {error}"),
    }
}

fn save_recovery_copy_with_dialog(world: &mut World) -> String {
    let (active_path, snapshot) = {
        let io = world.resource::<SceneIo>();
        (io.active_path.clone(), io.recovery_snapshot.clone())
    };
    let Some(snapshot) = snapshot else {
        return "No scene recovery snapshot is available".to_string();
    };
    let directory = active_path
        .as_deref()
        .and_then(Path::parent)
        .unwrap_or_else(|| Path::new("assets/levels"));
    let file_name = active_path
        .as_deref()
        .and_then(Path::file_name)
        .and_then(|name| name.to_str())
        .and_then(|name| name.strip_suffix(".scn.ron"))
        .map_or_else(
            || "recovered_scene.scn.ron".to_string(),
            |stem| format!("{stem}.recovered.scn.ron"),
        );
    let Some(destination) = rfd::FileDialog::new()
        .set_directory(directory)
        .add_filter("Bevy scene", &["scn.ron", "ron"])
        .set_file_name(file_name)
        .save_file()
    else {
        return "Save recovery copy cancelled".to_string();
    };
    let expected = match FileSnapshot::capture(&destination) {
        Ok(expected) => expected,
        Err(error) => return format!("Save recovery copy failed: {error}"),
    };

    match write_recovery_copy(world, &snapshot, &destination, &expected) {
        Ok(()) => format!(
            "Saved recovery copy {} from {}",
            destination.display(),
            snapshot.display()
        ),
        Err(error) => format!("Save recovery copy failed: {error}"),
    }
}

fn write_recovery_copy(
    world: &mut World,
    snapshot: &Path,
    destination: &Path,
    expected: &FileSnapshot,
) -> Result<(), String> {
    let bytes = std::fs::read(snapshot)
        .map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
    publish_authored_file(
        world,
        destination,
        &bytes,
        expected,
        FileWriteIntent::Standalone {
            description: "recovery scene copy".into(),
        },
    )
    .map(|_| ())
}

fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
    io.active_path = active_path;
    io.mark_dirty();
}

fn discard_recovery(world: &mut World) -> String {
    let (scene_path, snapshot) = {
        let io = world.resource::<SceneIo>();
        (io.active_path.clone(), io.recovery_snapshot.clone())
    };
    let (Some(scene_path), Some(snapshot)) = (scene_path, snapshot) else {
        return "No scene recovery snapshot is available".to_string();
    };
    let Some(state_root) = default_state_root() else {
        return "Recovery discard failed: HOME and XDG_STATE_HOME are unset".to_string();
    };
    let project_root = PathBuf::from(
        world
            .resource::<crate::project_io::ProjectWorkspace>()
            .root
            .clone(),
    );
    match discard_recovery_snapshots(&state_root, &project_root, &scene_path) {
        Ok(()) => {
            world.resource_mut::<SceneIo>().recovery_snapshot = None;
            format!(
                "Discarded recovery generations including {}",
                snapshot.display()
            )
        }
        Err(error) => format!("Recovery discard failed: {error}"),
    }
}

fn load_level(world: &mut World, path: &Path) -> Result<FileSnapshot, String> {
    if !path.exists() {
        return Err(format!("{} does not exist yet", path.display()));
    }

    let text = std::fs::read_to_string(path)
        .map_err(|err| format!("could not read {}: {err}", path.display()))?;
    let disk_snapshot = FileSnapshot::from_loaded_bytes(path, text.as_bytes());
    load_level_text(world, Some(path), &text)?;
    Ok(disk_snapshot)
}

pub(crate) fn reload_scene_after_file_conflict(
    world: &mut World,
    tab_id: u64,
    path: &Path,
) -> Result<String, String> {
    let active_matches = {
        let io = world.resource::<SceneIo>();
        io.tabs
            .get(io.active_tab)
            .is_some_and(|tab| tab.id == tab_id)
    };
    if !active_matches {
        return Err("the scene conflict no longer belongs to the active tab".into());
    }

    let disk_snapshot = load_level(world, path)?;
    {
        let mut io = world.resource_mut::<SceneIo>();
        io.active_path = Some(path.to_path_buf());
        io.recovery_snapshot = None;
        let active_tab = io.active_tab;
        io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
        io.mark_clean();
    }
    world.resource_mut::<EditorHistory>().clear();
    remember_path(world, path.to_path_buf());
    refresh_recovery_notice(world, path);
    capture_active_tab(world)?;
    Ok(format!("Reloaded {} from disk", path.display()))
}

pub(crate) fn adopt_scene_conflict_save_as(
    world: &mut World,
    tab_id: u64,
    path: PathBuf,
    disk_snapshot: FileSnapshot,
    entity_count: usize,
) -> Result<String, String> {
    let active_matches = {
        let io = world.resource::<SceneIo>();
        io.tabs
            .get(io.active_tab)
            .is_some_and(|tab| tab.id == tab_id)
    };
    if !active_matches {
        return Err("the scene conflict no longer belongs to the active tab".into());
    }

    {
        let mut io = world.resource_mut::<SceneIo>();
        io.active_path = Some(path.clone());
        let active_tab = io.active_tab;
        io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
        io.mark_clean();
    }
    remember_path(world, path.clone());
    retire_scene_recovery(world, &path);
    Ok(format!(
        "Saved {entity_count} level entities to {}",
        path.display()
    ))
}

fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
    validate_level_text(text)?;
    let document = SceneDocument::from_ron_text(text)?;
    if let Some(composition) = document.composition.as_ref() {
        scene::validate_scene_composition(composition)?;
    }
    let project_root = PathBuf::from(
        world
            .resource::<crate::project_io::ProjectWorkspace>()
            .root
            .clone(),
    )
    .canonicalize()
    .map_err(|error| format!("could not resolve project root: {error}"))?;
    let bevy_ron = strip_schema_version(document.normalized_ron())?;
    let dynamic_scene = deserialize_dynamic_scene(world, &bevy_ron)?;
    let composition = document
        .composition
        .unwrap_or_else(|| legacy_scene_composition(path));
    preflight_subscenes(&composition, &project_root)?;

    clear_scene_world(world);
    world.remove_resource::<SceneComposition>();

    dynamic_scene
        .write_to_world(world, &mut EntityHashMap::default())
        .map_err(|err| format!("could not spawn scene: {err}"))?;
    if !world.contains_resource::<SceneComposition>() {
        world.insert_resource(composition.clone());
    }

    let root_path = path.and_then(|path| path.canonicalize().ok());
    let mut visiting = root_path.into_iter().collect::<Vec<_>>();
    load_composed_subscenes(world, &composition, &project_root, &mut visiting, "", false)?;
    upgrade_legacy_prefab_instances(world);
    block_invalid_prefab_instances(world, &project_root);

    backfill_missing_actor_kinds(world);
    backfill_missing_sibling_indices(world);
    backfill_missing_editor_visibility(world);
    finalize_scene_load(world);

    if world.contains_resource::<crate::scene_schema::SceneSchemaState>() {
        world
            .resource_mut::<crate::scene_schema::SceneSchemaState>()
            .last_validated = scene::CURRENT_SCENE_SCHEMA_VERSION;
    }

    Ok(())
}

fn upgrade_legacy_prefab_instances(world: &mut World) -> usize {
    let legacy: Vec<(Entity, String)> = world
        .query_filtered::<(Entity, &PrefabRef), Without<PrefabInstance>>()
        .iter(world)
        .map(|(entity, prefab)| (entity, prefab.path.clone()))
        .collect();
    for (entity, path) in &legacy {
        world
            .entity_mut(*entity)
            .insert(PrefabInstance::new(format!("legacy:{path}"), path.clone()));
    }
    legacy.len()
}

fn block_invalid_prefab_instances(world: &mut World, project_root: &Path) -> usize {
    let instances: Vec<(Entity, PrefabInstance)> = world
        .query::<(Entity, &PrefabInstance)>()
        .iter(world)
        .map(|(entity, instance)| (entity, instance.clone()))
        .collect();
    let mut blocked = 0;
    for (entity, instance) in instances {
        match scene::validate_prefab_graph(&project_root.join(&instance.source_path), project_root)
        {
            Ok(()) => {
                world.entity_mut(entity).remove::<PrefabHydrationBlocked>();
            }
            Err(reason) => {
                blocked += 1;
                world
                    .entity_mut(entity)
                    .insert(PrefabHydrationBlocked { reason })
                    .remove::<PrefabRef>()
                    .remove::<HydratedPrefabReady>()
                    .remove::<DynamicWorldRoot>()
                    .remove::<WorldInstance>();
            }
        }
    }
    blocked
}

fn deserialize_dynamic_scene(world: &World, bevy_ron: &str) -> Result<DynamicWorld, String> {
    let registry = world.resource::<AppTypeRegistry>().read();
    let mut asset_server = world.resource::<AssetServer>().clone();
    let scene_deserializer = WorldDeserializer {
        type_registry: &registry,
        load_from_path: &mut asset_server,
    };
    let mut deserializer =
        ron::de::Deserializer::from_str(bevy_ron).map_err(|err| err.to_string())?;
    scene_deserializer
        .deserialize(&mut deserializer)
        .map_err(|err| format!("could not deserialize scene: {err}"))
}

fn legacy_scene_composition(path: Option<&Path>) -> SceneComposition {
    let scene_id = path.map_or_else(
        || uuid::Uuid::new_v4().to_string(),
        |path| format!("legacy:{}", path.to_string_lossy().replace('\\', "/")),
    );
    SceneComposition {
        scene_id,
        subscenes: Vec::new(),
    }
}

fn ensure_scene_composition_identity(world: &mut World) {
    if !world.contains_resource::<SceneComposition>() {
        let path = world.resource::<SceneIo>().active_path.clone();
        world.insert_resource(legacy_scene_composition(path.as_deref()));
    }
    if world
        .resource::<SceneComposition>()
        .scene_id
        .trim()
        .is_empty()
    {
        world.resource_mut::<SceneComposition>().scene_id = uuid::Uuid::new_v4().to_string();
    }
}

fn preflight_subscenes(composition: &SceneComposition, project_root: &Path) -> Result<(), String> {
    scene::validate_scene_composition(composition)?;
    for reference in &composition.subscenes {
        let target = resolve_subscene_path(project_root, &reference.path)?;
        scene::validate_composition_graph(&target, project_root)?;
    }
    Ok(())
}

fn resolve_subscene_path(project_root: &Path, relative: &str) -> Result<PathBuf, String> {
    let relative_path = Path::new(relative);
    if relative_path.is_absolute()
        || relative_path.components().any(|component| {
            matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        })
    {
        return Err(format!(
            "subscene path `{relative}` escapes the project root"
        ));
    }
    let target = project_root
        .join(relative_path)
        .canonicalize()
        .map_err(|error| {
            format!(
                "missing subscene `{relative}` under {}: {error}",
                project_root.display()
            )
        })?;
    if !target.starts_with(project_root) {
        return Err(format!(
            "subscene path `{relative}` escapes the project root"
        ));
    }
    Ok(target)
}

fn load_composed_subscenes(
    world: &mut World,
    composition: &SceneComposition,
    project_root: &Path,
    visiting: &mut Vec<PathBuf>,
    reference_prefix: &str,
    inherited_lock: bool,
) -> Result<(), String> {
    for reference in &composition.subscenes {
        if !reference.visible {
            continue;
        }
        let target = resolve_subscene_path(project_root, &reference.path)?;
        if let Some(index) = visiting.iter().position(|path| path == &target) {
            let mut cycle: Vec<String> = visiting[index..]
                .iter()
                .map(|path| {
                    path.strip_prefix(project_root)
                        .unwrap_or(path)
                        .display()
                        .to_string()
                })
                .collect();
            cycle.push(reference.path.clone());
            return Err(format!("cyclic subscene reference: {}", cycle.join(" -> ")));
        }

        let text = std::fs::read_to_string(&target)
            .map_err(|error| format!("could not read {}: {error}", target.display()))?;
        let document = SceneDocument::from_ron_text(&text)?;
        let scene_id = document
            .composition
            .as_ref()
            .map(|composition| composition.scene_id.clone())
            .unwrap_or_else(|| legacy_scene_composition(Some(&target)).scene_id);
        let bevy_ron = strip_schema_version(document.normalized_ron())?;
        let mut dynamic_scene = deserialize_dynamic_scene(world, &bevy_ron)?;
        dynamic_scene.resources.clear();
        let mut entity_map = EntityHashMap::default();
        dynamic_scene
            .write_to_world(world, &mut entity_map)
            .map_err(|error| format!("could not spawn subscene {}: {error}", target.display()))?;

        let reference_id = if reference_prefix.is_empty() {
            reference.id.clone()
        } else {
            format!("{reference_prefix}/{}", reference.id)
        };
        let locked = inherited_lock || reference.locked;
        let loaded_entities: Vec<Entity> = entity_map.values().copied().collect();
        for entity in &loaded_entities {
            world.entity_mut(*entity).insert(ComposedSceneMember {
                reference_id: reference_id.clone(),
                scene_id: scene_id.clone(),
                source_path: target.clone(),
            });
        }
        if locked {
            if let Some(mut hierarchy) =
                world.get_resource_mut::<crate::ui::hierarchy_state::HierarchyPanelState>()
            {
                hierarchy.locked.extend(loaded_entities.iter().copied());
            }
        }

        if let Some(child_composition) = document.composition {
            visiting.push(target);
            load_composed_subscenes(
                world,
                &child_composition,
                project_root,
                visiting,
                &reference_id,
                locked,
            )?;
            visiting.pop();
        }
    }
    Ok(())
}

fn finalize_scene_load(world: &mut World) {
    let rendering = world
        .resource::<settings::ProjectSettings>()
        .rendering
        .clone();
    world.insert_resource(GlobalAmbientLight {
        color: Color::srgb(
            rendering.ambient_color[0],
            rendering.ambient_color[1],
            rendering.ambient_color[2],
        ),
        brightness: rendering.ambient_brightness,
        ..default()
    });

    let mut state: SystemState<(
        Res<settings::ProjectSettings>,
        Query<
            (
                &LightDesc,
                Option<&AuthoringComponentStates>,
                Option<&InspectorOrder>,
            ),
            With<LevelObject>,
        >,
        Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
    )> = SystemState::new(world);

    {
        let (settings, scene_suns, project_suns) = state
            .get_mut(world)
            .expect("finalize_scene_load system params should be valid");
        game_hot::sync_project_sun_from_settings(settings, scene_suns, project_suns);
    }
    state.apply(world);
}

fn backfill_missing_actor_kinds(world: &mut World) {
    let mut query = world.query_filtered::<Entity, With<LevelObject>>();
    let entities: Vec<Entity> = query.iter(world).collect();
    for entity in entities {
        let Ok(entity_ref) = world.get_entity(entity) else {
            continue;
        };
        let current = entity_ref.get::<ActorKind>().copied();
        if let Some(kind) = infer_actor_kind(entity_ref).filter(|kind| current != Some(*kind)) {
            world.entity_mut(entity).insert(kind);
        }
    }
}

fn remember_path(world: &mut World, path: PathBuf) {
    let mut io = world.resource_mut::<SceneIo>();
    io.recent_paths.retain(|recent| recent != &path);
    io.recent_paths.insert(0, path.clone());
    io.recent_paths.truncate(8);

    if world.contains_resource::<crate::project_io::UserPreferences>() {
        let path_label = path.display().to_string();
        let mut prefs = world.resource_mut::<crate::project_io::UserPreferences>();
        crate::project_io::push_recent(&mut prefs.recent_levels, path_label, 8);
        if let Err(error) = crate::project_io::write_user_preferences(&prefs) {
            warn!("Failed to save editor preferences: {error}");
        }
    }
}

fn update_window_title(
    io: Res<SceneIo>,
    workspace: Res<crate::project_io::ProjectWorkspace>,
    settings: Res<settings::ProjectSettings>,
    settings_io: Res<settings::ProjectSettingsIo>,
    mut window: Single<&mut Window, With<PrimaryWindow>>,
) {
    if !io.is_changed()
        && !workspace.is_changed()
        && !settings.is_changed()
        && !settings_io.is_changed()
    {
        return;
    }

    window.title = crate::project_io::window_title(&io, &workspace, &settings);
}

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

    use bevy::asset::{AssetPath, LoadFromPath, UntypedHandle};
    use bevy::world_serialization::{
        DynamicWorldRoot, WorldInstanceSpawner, WorldSerializationPlugin,
    };

    struct NoAssetLoads;

    #[derive(Component, Reflect, Default)]
    #[reflect(Component, Default)]
    struct RegisteredSceneExtension {
        value: u32,
    }

    impl LoadFromPath for NoAssetLoads {
        fn load_from_path_erased(
            &mut self,
            _type_id: TypeId,
            _path: AssetPath<'static>,
        ) -> UntypedHandle {
            panic!("hierarchy fixture does not contain asset handles")
        }
    }

    #[test]
    fn registry_registered_extension_is_included_in_scene_persistence() {
        let mut app = App::new();
        app.register_type::<Name>()
            .register_type::<Transform>()
            .register_type::<LevelObject>()
            .register_type::<ActorKind>()
            .register_type::<SceneComposition>()
            .register_type::<RegisteredSceneExtension>();
        let mut component_registry =
            crate::ui::component_registry::EditorComponentRegistry::default();
        let mut descriptor = component_registry.descriptors[0].clone();
        descriptor.id = "game.registered_scene_extension";
        descriptor.type_name = std::any::type_name::<RegisteredSceneExtension>();
        descriptor.display_name = "Registered Scene Extension";
        descriptor.recommended = &[];
        descriptor.conflicts_with = &[];
        component_registry.register(descriptor).unwrap();
        app.insert_resource(component_registry);
        app.insert_resource(SceneComposition::default());
        let entity = app
            .world_mut()
            .spawn((
                Name::new("Extension"),
                Transform::default(),
                LevelObject,
                ActorKind::Empty,
                RegisteredSceneExtension { value: 73 },
            ))
            .id();

        let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();

        assert!(text.contains(std::any::type_name::<RegisteredSceneExtension>()));
        assert!(text.contains("value: 73") || text.contains("value:73"));
    }

    #[test]
    fn restored_recovery_remains_available_until_save_or_discard() {
        let authored = PathBuf::from("assets/levels/authored.scn.ron");
        let snapshot = PathBuf::from("/state/recovery/snapshot.scn.ron");
        let mut io = SceneIo {
            active_path: None,
            recovery_snapshot: Some(snapshot.clone()),
            ..default()
        };

        mark_recovery_restored(&mut io, Some(authored.clone()));

        assert_eq!(io.active_path, Some(authored));
        assert_eq!(io.recovery_snapshot, Some(snapshot));
        assert!(io.dirty);
    }

    #[test]
    fn active_tab_tracks_path_and_dirty_state() {
        let mut io = SceneIo::default();
        assert_eq!(io.tabs.len(), 1);
        assert!(!io.tabs[0].dirty);
        let initial_revision = io.change_revision();

        io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron"));
        io.mark_dirty();

        assert!(io.has_unsaved_tabs());
        assert!(io.change_revision() > initial_revision);
        assert!(io.tabs[0].dirty);
        assert_eq!(
            io.tabs[0].path.as_deref(),
            Some(Path::new("assets/levels/arena.scn.ron"))
        );
        io.mark_clean();
        assert!(!io.has_unsaved_tabs());

        io.tabs.push(SceneTab {
            id: 2,
            path: Some(PathBuf::from("assets/levels/lighting.scn.ron")),
            dirty: true,
            snapshot: "dirty inactive tab".to_string(),
            recovery_snapshot: None,
            disk_snapshot: None,
        });
        assert!(
            io.has_unsaved_tabs(),
            "inactive modified tabs must participate in save/recovery prompts"
        );
        let candidates = dirty_saved_tab_snapshots(&io);
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].0, 1);
        assert_eq!(
            candidates[0].1,
            PathBuf::from("assets/levels/lighting.scn.ron")
        );
    }

    #[test]
    fn active_scene_write_refuses_an_external_revision() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-scene-external-write-{}",
            uuid::Uuid::new_v4()
        ));
        let path = root.join("assets/levels/main.scn.ron");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, b"loaded").unwrap();
        let disk_snapshot = FileSnapshot::capture(&path).unwrap();
        let mut io = SceneIo {
            active_path: Some(path.clone()),
            ..Default::default()
        };
        io.tabs[0].path = Some(path.clone());
        io.tabs[0].disk_snapshot = Some(disk_snapshot);
        let mut world = World::new();
        world.insert_resource(io);
        std::fs::write(&path, b"external").unwrap();

        let result = publish_scene_text(&mut world, &path, b"editor", 1, SceneWriteContext::Active);

        assert!(result.unwrap_err().contains("changed outside Blacksite"));
        assert_eq!(std::fs::read(&path).unwrap(), b"external");
        assert!(world.resource::<SceneIo>().dirty);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn scene_save_as_refuses_a_file_created_after_destination_selection() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-scene-save-as-race-{}",
            uuid::Uuid::new_v4()
        ));
        let path = root.join("assets/levels/new.scn.ron");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        let expected = FileSnapshot::missing();
        let mut world = World::new();
        world.init_resource::<SceneIo>();
        std::fs::write(&path, b"external").unwrap();

        let result = publish_scene_text(
            &mut world,
            &path,
            b"editor",
            1,
            SceneWriteContext::ActiveSaveAs { expected },
        );

        assert!(result.unwrap_err().contains("changed outside Blacksite"));
        assert_eq!(std::fs::read(&path).unwrap(), b"external");
        assert!(world.resource::<SceneIo>().dirty);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn composition_edits_participate_in_undo_and_redo() {
        let mut app = App::new();
        app.init_resource::<SceneIo>()
            .init_resource::<EditorHistory>()
            .insert_resource(SceneComposition {
                scene_id: "main".to_string(),
                subscenes: Vec::new(),
            });
        let changed = SceneComposition {
            scene_id: "main".to_string(),
            subscenes: vec![shared::SubsceneReference {
                id: "lighting".to_string(),
                path: "assets/levels/lighting.scn.ron".to_string(),
                visible: true,
                locked: true,
            }],
        };

        crate::history::set_scene_composition_with_history(app.world_mut(), changed.clone());
        assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 1);
        assert_eq!(app.world().resource::<SceneComposition>(), &changed);

        crate::history::apply_command_undo(app.world_mut());
        assert!(app
            .world()
            .resource::<SceneComposition>()
            .subscenes
            .is_empty());
        crate::history::apply_command_redo(app.world_mut());
        assert_eq!(app.world().resource::<SceneComposition>(), &changed);
    }

    #[test]
    fn scene_finalize_defers_hydration_to_the_registered_update_pipeline() {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(AssetPlugin::default())
            .add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
            .init_asset::<Mesh>()
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut()
            .insert_resource(settings::ProjectSettings::default());

        let actor = app
            .world_mut()
            .spawn((
                LevelObject,
                BrushDesc::default(),
                ColliderDesc::static_cuboid(Vec3::ONE),
            ))
            .id();

        finalize_scene_load(app.world_mut());
        assert!(
            app.world().get::<Children>(actor).is_none(),
            "scene finalization must not create collider children ahead of Update"
        );

        app.update();
        let first_child = app
            .world()
            .get::<Children>(actor)
            .and_then(|children| children.first().copied())
            .expect("the registered hydration pipeline should create one brush child");
        assert!(app
            .world()
            .get::<avian3d::prelude::ColliderConstructor>(first_child)
            .is_some());

        app.update();
        assert_eq!(
            app.world()
                .get::<Children>(actor)
                .and_then(|children| children.first().copied()),
            Some(first_child),
            "unchanged authoring data must not replace freshly hydrated collider children"
        );
    }

    #[test]
    fn scene_serialization_persists_composition_without_composed_members() {
        let mut app = App::new();
        app.register_type::<Name>()
            .register_type::<Transform>()
            .register_type::<LevelObject>()
            .register_type::<ActorKind>()
            .register_type::<SceneComposition>()
            .register_type::<shared::SubsceneReference>();
        let world = app.world_mut();
        world.insert_resource(SceneComposition {
            scene_id: "main".to_string(),
            subscenes: vec![shared::SubsceneReference {
                id: "geometry".to_string(),
                path: "assets/levels/geometry.scn.ron".to_string(),
                visible: true,
                locked: true,
            }],
        });
        let owned = world
            .spawn((
                Name::new("Owned"),
                Transform::default(),
                LevelObject,
                ActorKind::Empty,
            ))
            .id();
        world.spawn((
            Name::new("Composed"),
            Transform::default(),
            LevelObject,
            ActorKind::Empty,
            ComposedSceneMember {
                reference_id: "geometry".to_string(),
                scene_id: "geometry-scene".to_string(),
                source_path: PathBuf::from("assets/levels/geometry.scn.ron"),
            },
        ));

        let text = serialize_entities_inner(world, vec![owned]).unwrap();
        let document = SceneDocument::from_ron_text(&text).unwrap();

        assert_eq!(document.entities.len(), 1);
        assert_eq!(document.entities[0].name.as_deref(), Some("Owned"));
        assert_eq!(
            document
                .composition
                .as_ref()
                .map(|value| value.scene_id.as_str()),
            Some("main")
        );
        assert_eq!(document.composition.unwrap().subscenes.len(), 1);

        let standalone =
            serialize_entities_inner_with_resources(world, vec![owned], false).unwrap();
        let standalone = SceneDocument::from_ron_text(&standalone).unwrap();
        assert!(
            standalone.composition.is_none(),
            "prefabs and selection exports must not carry level resources"
        );
    }

    #[test]
    fn audio_authoring_round_trip_excludes_runtime_playback_components() {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut().insert_resource(SceneComposition::default());
        let source = AudioSourceDesc {
            clip: Some(
                shared::EditorAssetRef::new("audio-id", shared::AUDIO_CLIP_SUB_ASSET_ID, "Impact")
                    .with_source_path("assets/audio/impact.ogg"),
            ),
            ..Default::default()
        };
        let entity = app
            .world_mut()
            .spawn((
                Name::new("Audio Source"),
                Transform::IDENTITY,
                LevelObject,
                ActorId::new("audio-source"),
                ActorKind::AudioSource,
                source.clone(),
            ))
            .id();

        let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
        let document = SceneDocument::from_ron_text(&text).unwrap();
        let component = document.entities[0]
            .components
            .iter()
            .find(|component| component.type_name == shared::COMPONENT_AUDIO_SOURCE_DESC)
            .expect("audio source descriptor persisted");
        let decoded: AudioSourceDesc = ron::from_str(&component.ron).unwrap();

        assert_eq!(decoded, source);
        assert!(!text.contains("bevy_audio::"));
        assert!(!text.contains("AuthoredAudioVoice"));
    }

    #[test]
    fn animation_controller_round_trip_excludes_runtime_animation_components() {
        use bevy::animation::prelude::{
            AnimationGraphHandle, AnimationPlayer, AnimationTransitions,
        };
        use shared::{
            animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationPlaybackRange,
            AnimationStateDesc, EditorAssetRef,
        };

        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut().insert_resource(SceneComposition::default());
        let source_path = "assets/models/animated.glb";
        let controller = AnimationControllerDesc {
            skeleton: Some(
                EditorAssetRef::new(
                    "animated-model-id",
                    animation_skeleton_sub_asset_id(0, "Rig"),
                    "Rig",
                )
                .with_source_path(source_path),
            ),
            states: vec![AnimationStateDesc {
                id: "locomotion.idle".into(),
                label: "Idle".into(),
                clip: EditorAssetRef::new(
                    "animated-model-id",
                    animation_clip_sub_asset_id(0, "Idle"),
                    "Idle",
                )
                .with_source_path(source_path),
                looping: true,
                speed: 1.0,
                range: AnimationPlaybackRange {
                    start_seconds: 0.1,
                    end_seconds: Some(1.25),
                },
            }],
            default_state: "locomotion.idle".into(),
            default_crossfade_seconds: 0.15,
        };
        let entity = app
            .world_mut()
            .spawn((
                Name::new("Animated Model"),
                Transform::IDENTITY,
                LevelObject,
                ActorId::new("animated-model"),
                ActorKind::SkinnedMesh,
                SkinnedMeshRenderer::new(source_path),
                controller.clone(),
                AnimationPlayer::default(),
                AnimationGraphHandle::default(),
                AnimationTransitions::new(),
            ))
            .id();

        let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
        let document = SceneDocument::from_ron_text(&text).unwrap();
        let component = document.entities[0]
            .components
            .iter()
            .find(|component| component.type_name == shared::COMPONENT_ANIMATION_CONTROLLER_DESC)
            .expect("animation controller descriptor persisted");
        let decoded: AnimationControllerDesc = ron::from_str(&component.ron).unwrap();

        assert_eq!(decoded, controller);
        assert!(text.contains("shared::animation::SkinnedMeshRenderer"));
        assert!(!text.contains("shared::components::ModelRef"));
        assert!(!text.contains("AnimationPlayer"));
        assert!(!text.contains("AnimationGraphHandle"));
        assert!(!text.contains("AnimationTransitions"));
    }

    #[test]
    fn linked_prefab_members_are_not_authored_scene_entities() {
        let mut world = World::new();
        let root = world.spawn((LevelObject, ActorKind::PrefabAnchor)).id();
        let authored = world.spawn((LevelObject, ActorKind::Empty)).id();
        let generated = world
            .spawn((
                LevelObject,
                ActorKind::Empty,
                HydratedPrefabMember {
                    instance_root: root,
                },
            ))
            .id();
        let entities = authored_scene_entities(&mut world);

        assert!(entities.contains(&root));
        assert!(entities.contains(&authored));
        assert!(!entities.contains(&generated));
    }

    #[test]
    fn serializing_prefab_anchor_preserves_live_world_instance() {
        let mut app = App::new();
        app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin))
            .register_type::<Name>()
            .register_type::<Transform>()
            .register_type::<LevelObject>()
            .register_type::<ActorId>()
            .register_type::<ActorKind>()
            .register_type::<PrefabInstance>();

        let mut source = World::new();
        source.spawn((LevelObject, ActorId::new("source-child")));
        let dynamic_world = DynamicWorld::from_world_with(
            &source,
            &app.world().resource::<AppTypeRegistry>().read(),
        );
        let handle = app
            .world_mut()
            .resource_mut::<Assets<DynamicWorld>>()
            .add(dynamic_world);
        let root = app
            .world_mut()
            .spawn((
                Name::new("Linked Prefab"),
                Transform::default(),
                LevelObject,
                ActorId::new("prefab-anchor"),
                ActorKind::PrefabAnchor,
                PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron"),
                DynamicWorldRoot(handle),
            ))
            .id();

        app.update();

        let instance_id = **app
            .world()
            .get::<WorldInstance>(root)
            .expect("prefab should be registered before save");
        let spawned_before: Vec<_> = app
            .world()
            .resource::<WorldInstanceSpawner>()
            .iter_instance_entities(instance_id)
            .collect();
        assert_eq!(spawned_before.len(), 1);

        serialize_entities_inner_with_resources(app.world_mut(), vec![root], false).unwrap();

        assert!(app.world().get::<DynamicWorldRoot>(root).is_some());
        assert_eq!(
            app.world().get::<WorldInstance>(root).map(|value| **value),
            Some(instance_id)
        );
        let spawner = app.world().resource::<WorldInstanceSpawner>();
        assert!(spawner.instance_is_ready(instance_id));
        assert_eq!(
            spawner
                .iter_instance_entities(instance_id)
                .collect::<Vec<_>>(),
            spawned_before
        );
    }

    #[test]
    fn prefab_export_expands_local_children_and_nested_instances_only() {
        let mut world = World::new();
        let root = world.spawn((LevelObject, PrefabInstance::default())).id();
        let local = world.spawn((LevelObject, ChildOf(root))).id();
        let nested = world
            .spawn((LevelObject, PrefabInstance::default(), ChildOf(local)))
            .id();
        let generated = world
            .spawn((
                LevelObject,
                ChildOf(root),
                HydratedPrefabMember {
                    instance_root: root,
                },
            ))
            .id();
        let generated_descendant = world.spawn((LevelObject, ChildOf(generated))).id();

        let expanded = expand_authored_selection(&world, &[root]);

        assert!(expanded.contains(&root));
        assert!(expanded.contains(&local));
        assert!(expanded.contains(&nested));
        assert!(!expanded.contains(&generated));
        assert!(!expanded.contains(&generated_descendant));
    }

    #[test]
    fn standalone_export_detaches_external_parent_and_restores_live_hierarchy() {
        let mut app = App::new();
        app.register_type::<Name>()
            .register_type::<Transform>()
            .register_type::<ChildOf>()
            .register_type::<LevelObject>()
            .register_type::<ActorId>()
            .register_type::<ActorKind>();
        let world = app.world_mut();
        let parent = world
            .spawn((
                Name::new("Scene Parent"),
                Transform::from_xyz(10.0, 0.0, 0.0),
                LevelObject,
                ActorId::new("parent"),
                ActorKind::Empty,
            ))
            .id();
        let child = world
            .spawn((
                Name::new("Exported Root"),
                Transform::from_xyz(1.0, 2.0, 3.0),
                LevelObject,
                ActorId::new("child"),
                ActorKind::Empty,
                ChildOf(parent),
            ))
            .id();

        let text = serialize_entities_inner_with_resources(world, vec![child], false).unwrap();

        assert!(!text.contains("bevy_ecs::hierarchy::ChildOf"));
        assert_eq!(world.get::<ChildOf>(child).unwrap().parent(), parent);
        assert_eq!(
            world.get::<Transform>(child).unwrap().translation,
            Vec3::new(1.0, 2.0, 3.0),
            "standalone export must restore the live local transform"
        );
        let document = SceneDocument::from_ron_text(&text).unwrap();
        assert_eq!(document.entities.len(), 1);
        assert_eq!(document.entities[0].actor_id.as_deref(), Some("child"));
        let transform = document.entities[0]
            .components
            .iter()
            .find(|component| component.type_name == scene::document::TRANSFORM_COMPONENT)
            .and_then(|component| ron::from_str::<Transform>(&component.ron).ok())
            .expect("standalone root should retain a serialized transform");
        assert_eq!(transform.translation, Vec3::new(11.0, 2.0, 3.0));
    }

    #[test]
    fn prefab_export_backfills_missing_and_duplicate_actor_ids() {
        let mut world = World::new();
        let existing = world.spawn((LevelObject, ActorId::new("occupied"))).id();
        let duplicate = world.spawn((LevelObject, ActorId::new("occupied"))).id();
        let missing = world.spawn(LevelObject).id();

        assert_eq!(
            ensure_unique_actor_ids(&mut world, &[duplicate, missing]),
            2
        );

        let existing_id = world.get::<ActorId>(existing).unwrap().0.clone();
        let duplicate_id = world.get::<ActorId>(duplicate).unwrap().0.clone();
        let missing_id = world.get::<ActorId>(missing).unwrap().0.clone();
        assert_eq!(existing_id, "occupied");
        assert_ne!(duplicate_id, existing_id);
        assert_ne!(missing_id, existing_id);
        assert_ne!(missing_id, duplicate_id);
    }

    #[test]
    fn scene_serialization_round_trip_preserves_parent_and_manual_sibling_order() {
        let mut app = App::new();
        app.register_type::<Name>()
            .register_type::<Transform>()
            .register_type::<ChildOf>()
            .register_type::<LevelObject>()
            .register_type::<ActorKind>()
            .register_type::<HierarchySiblingIndex>();
        let world = app.world_mut();
        let parent = world
            .spawn((
                Name::new("Parent"),
                Transform::default(),
                LevelObject,
                ActorKind::Empty,
                HierarchySiblingIndex(0),
            ))
            .id();
        let second = world
            .spawn((
                Name::new("Second"),
                Transform::default(),
                LevelObject,
                ActorKind::Empty,
                HierarchySiblingIndex(1),
                ChildOf(parent),
            ))
            .id();
        let first = world
            .spawn((
                Name::new("First"),
                Transform::default(),
                LevelObject,
                ActorKind::Empty,
                HierarchySiblingIndex(0),
                ChildOf(parent),
            ))
            .id();

        let text = serialize_entities_inner(world, vec![parent, second, first]).unwrap();
        world.despawn(first);
        world.despawn(second);
        world.despawn(parent);

        let bevy_ron = strip_schema_version(
            SceneDocument::from_ron_text(&text)
                .unwrap()
                .normalized_ron(),
        )
        .unwrap();
        let dynamic_world: DynamicWorld = {
            let registry = world.resource::<AppTypeRegistry>().read();
            let mut no_assets = NoAssetLoads;
            let mut deserializer = ron::de::Deserializer::from_str(&bevy_ron).unwrap();
            WorldDeserializer {
                type_registry: &registry,
                load_from_path: &mut no_assets,
            }
            .deserialize(&mut deserializer)
            .unwrap()
        };
        dynamic_world
            .write_to_world(world, &mut EntityHashMap::default())
            .unwrap();

        let mut query = world.query::<(Entity, &Name, &HierarchySiblingIndex, Option<&ChildOf>)>();
        let rows: Vec<_> = query
            .iter(world)
            .map(|(entity, name, index, parent)| {
                (
                    entity,
                    name.to_string(),
                    index.0,
                    parent.map(ChildOf::parent),
                )
            })
            .collect();
        let parent = rows
            .iter()
            .find(|(_, name, _, _)| name == "Parent")
            .map(|(entity, _, _, _)| *entity)
            .unwrap();
        let first = rows.iter().find(|(_, name, _, _)| name == "First").unwrap();
        let second = rows
            .iter()
            .find(|(_, name, _, _)| name == "Second")
            .unwrap();

        assert_eq!((first.2, first.3), (0, Some(parent)));
        assert_eq!((second.2, second.3), (1, Some(parent)));
    }

    #[test]
    fn composed_subscene_loads_with_stable_ownership_and_lock() {
        let root =
            std::env::temp_dir().join(format!("blacksite-composed-load-{}", uuid::Uuid::new_v4()));
        let levels = root.join("assets/levels");
        std::fs::create_dir_all(&levels).unwrap();
        std::fs::write(root.join("assets/project.ron"), "()").unwrap();
        let main_path = levels.join("main.scn.ron");
        let child_path = levels.join("geometry.scn.ron");
        std::fs::write(
            &main_path,
            r#"(schema_version: 2,
  resources: {
    "shared::components::SceneComposition": (
      scene_id: "main-scene",
      subscenes: [(
        id: "geometry-ref",
        path: "assets/levels/geometry.scn.ron",
        visible: true,
        locked: true,
      )],
    ),
  },
  entities: {},
)"#,
        )
        .unwrap();
        std::fs::write(
            &child_path,
            r#"(schema_version: 2,
  resources: {
    "shared::components::SceneComposition": (
      scene_id: "geometry-scene",
      subscenes: [],
    ),
  },
  entities: {
    1: (components: {
      "bevy_ecs::name::Name": "Composed Geometry",
      "bevy_transform::components::transform::Transform": (
        translation: (0.0, 0.0, 0.0),
        rotation: (0.0, 0.0, 0.0, 1.0),
        scale: (1.0, 1.0, 1.0),
      ),
      "shared::components::LevelObject": (),
      "shared::components::ActorKind": Empty,
    }),
  },
)"#,
        )
        .unwrap();

        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(AssetPlugin::default())
            .add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
            .init_asset::<Mesh>()
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut()
            .insert_resource(settings::ProjectSettings::default());
        app.world_mut()
            .insert_resource(crate::project_io::ProjectWorkspace {
                root: root.display().to_string(),
                ..Default::default()
            });
        app.world_mut()
            .insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());

        load_level(app.world_mut(), &main_path).unwrap();

        let world = app.world_mut();
        assert_eq!(world.resource::<SceneComposition>().scene_id, "main-scene");
        let mut query = world.query::<(Entity, &Name, &ComposedSceneMember)>();
        let rows: Vec<_> = query
            .iter(world)
            .map(|(entity, name, member)| {
                (
                    entity,
                    name.to_string(),
                    member.reference_id.clone(),
                    member.scene_id.clone(),
                    member.source_path.clone(),
                )
            })
            .collect();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].1, "Composed Geometry");
        assert_eq!(rows[0].2, "geometry-ref");
        assert_eq!(rows[0].3, "geometry-scene");
        assert_eq!(rows[0].4, child_path.canonicalize().unwrap());
        assert!(world
            .resource::<crate::ui::hierarchy_state::HierarchyPanelState>()
            .locked
            .contains(&rows[0].0));

        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn broken_prefab_source_loads_as_repairable_blocked_anchor() {
        let root =
            std::env::temp_dir().join(format!("blacksite-broken-prefab-{}", uuid::Uuid::new_v4()));
        let levels = root.join("assets/levels");
        std::fs::create_dir_all(&levels).unwrap();
        std::fs::write(root.join("assets/project.ron"), "()").unwrap();
        let level_path = levels.join("main.scn.ron");
        std::fs::write(
            &level_path,
            r#"(schema_version: 2, resources: {}, entities: {
  1: (components: {
    "bevy_ecs::name::Name": "Broken Prefab",
    "bevy_transform::components::transform::Transform": (
      translation: (0.0, 0.0, 0.0),
      rotation: (0.0, 0.0, 0.0, 1.0),
      scale: (1.0, 1.0, 1.0),
    ),
    "shared::components::LevelObject": (),
    "shared::components::ActorId": ("broken-anchor"),
    "shared::components::ActorKind": PrefabAnchor,
    "shared::components::PrefabRef": (path: "assets/prefabs/missing.scn.ron"),
    "shared::components::PrefabInstance": (
      asset_id: "missing-asset",
      source_path: "assets/prefabs/missing.scn.ron",
      overrides_ron: None,
    ),
  }),
  2: (components: {
    "bevy_ecs::name::Name": "Broken Legacy Prefab",
    "bevy_transform::components::transform::Transform": (
      translation: (0.0, 0.0, 0.0),
      rotation: (0.0, 0.0, 0.0, 1.0),
      scale: (1.0, 1.0, 1.0),
    ),
    "shared::components::LevelObject": (),
    "shared::components::ActorId": ("broken-legacy-anchor"),
    "shared::components::ActorKind": PrefabAnchor,
    "shared::components::PrefabRef": (path: "assets/prefabs/legacy-missing.scn.ron"),
  }),
})"#,
        )
        .unwrap();

        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(AssetPlugin::default())
            .add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
            .init_asset::<Mesh>()
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut()
            .insert_resource(settings::ProjectSettings::default());
        app.world_mut()
            .insert_resource(crate::project_io::ProjectWorkspace {
                root: root.display().to_string(),
                ..Default::default()
            });
        app.world_mut()
            .insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());

        load_level(app.world_mut(), &level_path).unwrap();

        let world = app.world_mut();
        let blocked: Vec<_> = world
            .query::<(Entity, &PrefabHydrationBlocked)>()
            .iter(world)
            .map(|(entity, blocked)| (entity, blocked.reason.clone()))
            .collect();
        assert_eq!(blocked.len(), 2);
        for (entity, reason) in blocked {
            assert!(reason.contains("missing prefab source"));
            assert!(world.get::<PrefabInstance>(entity).is_some());
            assert!(world.get::<DynamicWorldRoot>(entity).is_none());
            assert!(world.get::<HydratedPrefabReady>(entity).is_none());
        }
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn saving_open_legacy_prefab_repairs_actor_ids() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-legacy-prefab-save-{}",
            uuid::Uuid::new_v4()
        ));
        let prefabs = root.join("assets/prefabs");
        std::fs::create_dir_all(&prefabs).unwrap();
        std::fs::write(root.join("assets/project.ron"), "()").unwrap();
        let prefab_path = prefabs.join("legacy.scn.ron");
        std::fs::write(
            &prefab_path,
            r#"(schema_version: 2, resources: {}, entities: {
  1: (components: {
    "bevy_ecs::name::Name": "Missing ID",
    "bevy_transform::components::transform::Transform": (
      translation: (0.0, 0.0, 0.0),
      rotation: (0.0, 0.0, 0.0, 1.0),
      scale: (1.0, 1.0, 1.0),
    ),
    "shared::components::LevelObject": (),
    "shared::components::ActorKind": Empty,
  }),
  2: (components: {
    "bevy_ecs::name::Name": "Duplicate A",
    "bevy_transform::components::transform::Transform": (
      translation: (1.0, 0.0, 0.0),
      rotation: (0.0, 0.0, 0.0, 1.0),
      scale: (1.0, 1.0, 1.0),
    ),
    "shared::components::LevelObject": (),
    "shared::components::ActorId": ("duplicate"),
    "shared::components::ActorKind": Empty,
  }),
  3: (components: {
    "bevy_ecs::name::Name": "Duplicate B",
    "bevy_transform::components::transform::Transform": (
      translation: (2.0, 0.0, 0.0),
      rotation: (0.0, 0.0, 0.0, 1.0),
      scale: (1.0, 1.0, 1.0),
    ),
    "shared::components::LevelObject": (),
    "shared::components::ActorId": ("duplicate"),
    "shared::components::ActorKind": Empty,
  }),
})"#,
        )
        .unwrap();

        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(AssetPlugin::default())
            .add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
            .init_asset::<Mesh>()
            .add_plugins(shared::SharedTypesPlugin);
        app.world_mut()
            .insert_resource(settings::ProjectSettings::default());
        app.world_mut()
            .insert_resource(crate::project_io::ProjectWorkspace {
                root: root.display().to_string(),
                ..Default::default()
            });
        app.world_mut()
            .insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
        app.world_mut().init_resource::<SceneIo>();

        let expected = load_level(app.world_mut(), &prefab_path).unwrap();
        save_level(
            app.world_mut(),
            &prefab_path,
            SceneWriteContext::ActiveSaveAs { expected },
        )
        .unwrap();

        scene::validate_prefab_graph(&prefab_path, &root).unwrap();
        let document =
            SceneDocument::from_ron_text(&std::fs::read_to_string(&prefab_path).unwrap()).unwrap();
        let ids: HashSet<_> = document
            .entities
            .iter()
            .map(|entity| entity.actor_id.clone().expect("ActorId must be repaired"))
            .collect();
        assert_eq!(ids.len(), 3);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn scene_io_event_log_is_bounded_and_classifies_failures() {
        let mut io = SceneIo::default();
        for index in 0..(MAX_SCENE_IO_EVENTS + 3) {
            io.set_status(format!("Saved scene {index}"));
        }
        io.set_status("Recovery snapshot failed: disk full");

        assert_eq!(io.events.len(), MAX_SCENE_IO_EVENTS);
        assert_eq!(
            io.events.back().map(|event| event.severity),
            Some(SceneIoEventSeverity::Error)
        );
        assert_eq!(
            io.events.front().map(|event| event.id),
            Some(5),
            "oldest entries should be evicted first"
        );
    }

    #[test]
    fn recovery_copy_preserves_snapshot_bytes_without_retiring_source() {
        let root =
            std::env::temp_dir().join(format!("blacksite-recovery-copy-{}", uuid::Uuid::new_v4()));
        let snapshot = root.join("recovery.scn.ron");
        let destination = root.join("kept.scn.ron");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
        let expected = FileSnapshot::missing();
        let mut world = World::new();
        world.init_resource::<SceneIo>();

        write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap();

        assert_eq!(
            std::fs::read(&destination).unwrap(),
            b"recovered scene bytes"
        );
        assert!(snapshot.exists());
        std::fs::remove_dir_all(root).unwrap();
    }
}
