use super::*;

pub(super) fn tick_asset_recovery(world: &mut World) {
    let (ready, stale) = {
        let now = Instant::now();
        let store = world.resource::<AuthoredAssetDocumentStore>();
        let ready = store
            .documents
            .values()
            .filter(|document| {
                document.dirty()
                    && document.recovery_revision != Some(document.revision)
                    && document
                        .changed_at
                        .is_some_and(|changed| now.duration_since(changed) >= RECOVERY_IDLE)
            })
            .map(|document| document.key.clone())
            .collect::<Vec<_>>();
        let stale = stale_recovery_keys(store);
        (ready, stale)
    };
    for key in stale {
        if let Err(error) = remove_document_recovery(world, &key) {
            if let Some(document) = world
                .resource_mut::<AuthoredAssetDocumentStore>()
                .documents
                .get_mut(&key)
            {
                document.error = Some(format!("Recovery cleanup failed: {error}"));
            }
            continue;
        }
        if let Some(document) = world
            .resource_mut::<AuthoredAssetDocumentStore>()
            .documents
            .get_mut(&key)
        {
            document.recovery_revision = None;
        }
    }
    for key in ready {
        if let Err(error) = write_document_recovery(world, &key) {
            if let Some(document) = world
                .resource_mut::<AuthoredAssetDocumentStore>()
                .documents
                .get_mut(&key)
            {
                document.error = Some(format!("Recovery snapshot failed: {error}"));
            }
        }
    }
}

fn stale_recovery_keys(store: &AuthoredAssetDocumentStore) -> Vec<AuthoredAssetDocumentKey> {
    store
        .documents
        .values()
        .filter(|document| !document.dirty() && document.recovery_revision.is_some())
        .map(|document| document.key.clone())
        .collect()
}

pub(super) fn write_document_recovery(
    world: &mut World,
    key: &AuthoredAssetDocumentKey,
) -> Result<(), String> {
    let root = project_root(world);
    let state_root =
        default_state_root().ok_or_else(|| "editor state directory is unavailable".to_string())?;
    let (envelope, revision) = {
        let store = world.resource::<AuthoredAssetDocumentStore>();
        let document = store
            .documents
            .get(key)
            .ok_or_else(|| "recovery document is missing".to_string())?;
        (
            RecoveryEnvelope {
                key: key.clone(),
                path: document.path.clone(),
                baseline_revision: revision_label(&document.disk_snapshot.revision),
                value: document.current.clone(),
            },
            document.revision,
        )
    };
    let directory = recovery_directory(&state_root, &root, key);
    fs::create_dir_all(&directory)
        .map_err(|error| format!("could not create {}: {error}", directory.display()))?;
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    let path = directory.join(format!("recovery-{timestamp:020}.ron"));
    let bytes = ron::ser::to_string_pretty(&envelope, ron::ser::PrettyConfig::default())
        .map_err(|error| format!("could not serialize asset recovery: {error}"))?;
    atomic_write(&path, bytes.as_bytes())?;
    prune_recovery(&directory)?;
    if let Some(document) = world
        .resource_mut::<AuthoredAssetDocumentStore>()
        .documents
        .get_mut(key)
    {
        document.recovery_revision = Some(revision);
    }
    Ok(())
}

pub(super) fn discover_asset_recovery(world: &mut World) {
    let root = project_root(world);
    let Some(state_root) = default_state_root() else {
        return;
    };
    let base = state_root
        .join("asset-recovery")
        .join(blake3::hash(normalize_path(&root.to_string_lossy()).as_bytes()).to_hex());
    let Ok(entries) = fs::read_dir(base) else {
        return;
    };
    let mut recovered = Vec::new();
    for directory in entries.filter_map(Result::ok).map(|entry| entry.path()) {
        let Ok(files) = fs::read_dir(directory) else {
            continue;
        };
        let latest = files
            .filter_map(Result::ok)
            .map(|entry| entry.path())
            .max_by_key(|path| path.file_name().map(|name| name.to_os_string()));
        if let Some(envelope) = latest
            .and_then(|path| fs::read_to_string(path).ok())
            .and_then(|source| ron::from_str::<RecoveryEnvelope>(&source).ok())
        {
            if !recovery_requires_prompt(
                load_clean_recovery_value(world, &envelope).as_ref(),
                &envelope.value,
            ) {
                let _ = remove_document_recovery(world, &envelope.key);
                continue;
            }
            recovered.push(envelope);
        }
    }
    if !recovered.is_empty() {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        store.pending_recovery = recovered;
        store.recovery_prompt_open = true;
    }
}

fn recovery_requires_prompt(
    clean: Option<&AuthoredAssetValue>,
    recovered: &AuthoredAssetValue,
) -> bool {
    clean != Some(recovered)
}

pub fn asset_recovery_modal(world: &mut World, ctx: &bevy_egui::egui::Context) {
    let open = world
        .resource::<AuthoredAssetDocumentStore>()
        .recovery_prompt_open;
    if !open {
        return;
    }
    let pending = world
        .resource::<AuthoredAssetDocumentStore>()
        .pending_recovery
        .clone();
    let mut restore = false;
    let mut discard = false;
    let mut review = false;
    let mut restore_one = None;
    let mut discard_one = None;
    let reviewing = world
        .resource::<AuthoredAssetDocumentStore>()
        .recovery_reviewing;
    bevy_egui::egui::Modal::new(bevy_egui::egui::Id::new("asset_document_recovery")).show(ctx, |ui| {
        ui.heading("Unsaved asset recovery");
        ui.label("Blacksite found editor-local asset edits that were never written to project source files.");
        bevy_egui::egui::ScrollArea::vertical()
            .max_height(180.0)
            .show(ui, |ui| {
                for envelope in &pending {
                    ui.horizontal(|ui| {
                        ui.strong(envelope.key.kind.slug());
                        ui.monospace(&envelope.path);
                        if reviewing {
                            if ui.small_button("Restore").clicked() {
                                restore_one = Some(envelope.key.clone());
                            }
                            if ui.small_button("Discard").clicked() {
                                discard_one = Some(envelope.key.clone());
                            }
                        }
                    });
                }
            });
        ui.horizontal(|ui| {
            restore = ui.button("Restore All").clicked();
            review = ui.button("Review").clicked();
            discard = ui.button("Discard All").clicked();
        });
    });
    if let Some(key) = restore_one {
        restore_pending_recovery_key(world, &key, true);
    } else if let Some(key) = discard_one {
        restore_pending_recovery_key(world, &key, false);
    } else if review {
        world
            .resource_mut::<AuthoredAssetDocumentStore>()
            .recovery_reviewing = true;
    } else if restore {
        restore_pending_recovery(world);
    } else if discard {
        discard_pending_recovery(world);
    }
}

pub(crate) fn reload_registry_documents_after_file_conflict(
    world: &mut World,
) -> Result<String, String> {
    let root = project_root(world);
    let path = root.join(content_pipeline::REGISTRY_PATH);
    let source = fs::read_to_string(&path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    let loaded = shared::parse_asset_registry(&source)?;
    let snapshot = FileSnapshot::from_loaded_bytes(&path, source.as_bytes());
    let registry_kinds = [
        AuthoredAssetDocumentKind::ModelImportSettings,
        AuthoredAssetDocumentKind::TextureImportSettings,
        AuthoredAssetDocumentKind::ProjectContentDefaults,
    ];
    let keys = world
        .resource::<AuthoredAssetDocumentStore>()
        .documents
        .keys()
        .filter(|key| registry_kinds.contains(&key.kind))
        .cloned()
        .collect::<Vec<_>>();
    for key in &keys {
        discard_document_recovery(world, key);
    }
    {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        for key in &keys {
            store.documents.remove(key);
        }
        store
            .path_index
            .retain(|_, key| !registry_kinds.contains(&key.kind));
        store.registry_clean = Some(loaded.document.clone());
        store.registry_snapshot = Some(snapshot);
    }
    apply_registry_to_world(world, &loaded.document);
    Ok(format!("Reloaded asset registry from {}", path.display()))
}

pub(crate) fn adopt_external_registry_document(
    world: &mut World,
    document: &AssetRegistryDocument,
) {
    if !world.contains_resource::<AuthoredAssetDocumentStore>() {
        apply_registry_to_world(world, document);
        return;
    }
    let path = project_root(world).join(content_pipeline::REGISTRY_PATH);
    let snapshot = FileSnapshot::capture(&path).unwrap_or_else(|_| FileSnapshot::missing());
    {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        store.registry_clean = Some(document.clone());
        store.registry_snapshot = Some(snapshot);
    }
    apply_registry_to_world(world, document);
    reapply_unsaved_registry_overlays(world);
}

pub(super) fn restore_pending_recovery(world: &mut World) {
    let pending = {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        store.recovery_prompt_open = false;
        std::mem::take(&mut store.pending_recovery)
    };
    for envelope in pending {
        restore_recovery_envelope(world, envelope);
    }
    reapply_unsaved_registry_overlays(world);
}

pub(super) fn restore_pending_recovery_key(
    world: &mut World,
    key: &AuthoredAssetDocumentKey,
    restore: bool,
) {
    let envelope = {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        let Some(index) = store
            .pending_recovery
            .iter()
            .position(|envelope| &envelope.key == key)
        else {
            return;
        };
        let envelope = store.pending_recovery.remove(index);
        if store.pending_recovery.is_empty() {
            store.recovery_prompt_open = false;
            store.recovery_reviewing = false;
        }
        envelope
    };
    if restore {
        restore_recovery_envelope(world, envelope);
        reapply_unsaved_registry_overlays(world);
    } else {
        discard_document_recovery(world, &envelope.key);
    }
}

pub(super) fn restore_recovery_envelope(world: &mut World, envelope: RecoveryEnvelope) {
    let snapshot_path = source_path_for_recovery(world, &envelope);
    let snapshot =
        FileSnapshot::capture(&snapshot_path).unwrap_or_else(|_| FileSnapshot::missing());
    let state = if revision_label(&snapshot.revision) == envelope.baseline_revision {
        AuthoredDocumentState::Dirty
    } else {
        AuthoredDocumentState::ExternalConflict
    };
    let clean =
        load_clean_recovery_value(world, &envelope).unwrap_or_else(|| envelope.value.clone());
    let mut document =
        AuthoredAssetDocument::new(envelope.key.clone(), envelope.path.clone(), clean, snapshot);
    document.current = envelope.value;
    document.revision = 1;
    document.state = state;
    document.changed_at = Some(Instant::now());
    if state == AuthoredDocumentState::ExternalConflict {
        document.error = Some("Source changed since this recovery snapshot was created".into());
    }
    let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
    store
        .path_index
        .insert(normalize_path(&document.path), envelope.key.clone());
    store.documents.insert(envelope.key.clone(), document);
    store.active = Some(envelope.key);
}

pub(super) fn discard_pending_recovery(world: &mut World) {
    let keys = {
        let mut store = world.resource_mut::<AuthoredAssetDocumentStore>();
        store.recovery_prompt_open = false;
        store.recovery_reviewing = false;
        std::mem::take(&mut store.pending_recovery)
            .into_iter()
            .map(|envelope| envelope.key)
            .collect::<Vec<_>>()
    };
    for key in keys {
        discard_document_recovery(world, &key);
    }
}

pub(super) fn load_clean_recovery_value(
    world: &World,
    envelope: &RecoveryEnvelope,
) -> Option<AuthoredAssetValue> {
    match envelope.key.kind {
        AuthoredAssetDocumentKind::Material => MaterialAsset::load_from_path(&envelope.path)
            .ok()
            .map(AuthoredAssetValue::Material),
        AuthoredAssetDocumentKind::MaterialInstance => {
            MaterialInstanceAsset::load_from_path(&envelope.path)
                .ok()
                .map(AuthoredAssetValue::MaterialInstance)
        }
        AuthoredAssetDocumentKind::ModelImportSettings => world
            .resource::<AssetRegistry>()
            .records
            .iter()
            .find(|record| record.id.as_string() == envelope.key.asset_id)
            .map(|record| AuthoredAssetValue::ModelImportSettings(record.model_import().clone())),
        AuthoredAssetDocumentKind::TextureImportSettings => world
            .resource::<AssetRegistry>()
            .records
            .iter()
            .find(|record| record.id.as_string() == envelope.key.asset_id)
            .and_then(|record| record.texture_import().cloned())
            .map(AuthoredAssetValue::TextureImportSettings),
        AuthoredAssetDocumentKind::ProjectContentDefaults => {
            Some(AuthoredAssetValue::ProjectContentDefaults(
                world.resource::<AssetRegistry>().defaults.clone(),
            ))
        }
    }
}

pub(super) fn source_path_for_recovery(world: &World, envelope: &RecoveryEnvelope) -> PathBuf {
    match envelope.key.kind {
        AuthoredAssetDocumentKind::Material | AuthoredAssetDocumentKind::MaterialInstance => {
            PathBuf::from(&envelope.path)
        }
        _ => project_root(world).join(content_pipeline::REGISTRY_PATH),
    }
}

pub(super) fn discard_document_recovery(world: &World, key: &AuthoredAssetDocumentKey) {
    let _ = remove_document_recovery(world, key);
}

fn remove_document_recovery(world: &World, key: &AuthoredAssetDocumentKey) -> Result<(), String> {
    let Some(state_root) = default_state_root() else {
        return Ok(());
    };
    let directory = recovery_directory(&state_root, &project_root(world), key);
    match fs::remove_dir_all(&directory) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(format!("could not remove {}: {error}", directory.display())),
    }
}

pub(super) fn recovery_directory(
    state_root: &Path,
    project_root: &Path,
    key: &AuthoredAssetDocumentKey,
) -> PathBuf {
    let project = blake3::hash(normalize_path(&project_root.to_string_lossy()).as_bytes());
    state_root
        .join("asset-recovery")
        .join(project.to_hex())
        .join(format!("{}-{}", key.kind.slug(), key.asset_id))
}

pub(super) fn prune_recovery(directory: &Path) -> Result<(), String> {
    let mut files = fs::read_dir(directory)
        .map_err(|error| format!("could not read {}: {error}", directory.display()))?
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .collect::<Vec<_>>();
    files.sort();
    let remove = files.len().saturating_sub(RECOVERY_GENERATIONS);
    for path in files.into_iter().take(remove) {
        fs::remove_file(&path)
            .map_err(|error| format!("could not prune {}: {error}", path.display()))?;
    }
    Ok(())
}

pub(super) fn revision_label(revision: &FileRevision) -> String {
    match revision {
        FileRevision::Missing => "missing".into(),
        FileRevision::Present(hash) => hash.iter().map(|byte| format!("{byte:02x}")).collect(),
    }
}

pub(super) fn project_root(world: &World) -> PathBuf {
    world
        .get_resource::<ProjectWorkspace>()
        .map(|workspace| PathBuf::from(&workspace.root))
        .unwrap_or_else(|| PathBuf::from("."))
}

pub(super) fn normalize_path(path: &str) -> String {
    path.replace('\\', "/")
}

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

    fn material(label: &str) -> MaterialAsset {
        MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: label.into(),
            shader: Default::default(),
            shader_ref: None,
            render_state: Default::default(),
            provenance: None,
            inputs: Default::default(),
        }
    }

    #[test]
    fn dirty_state_is_derived_from_the_clean_document() {
        let key = AuthoredAssetDocumentKey::new("material-id", AuthoredAssetDocumentKind::Material);
        let mut store = AuthoredAssetDocumentStore::default();
        store.documents.insert(
            key.clone(),
            AuthoredAssetDocument::new(
                key.clone(),
                "assets/materials/test.ron".into(),
                AuthoredAssetValue::Material(material("Clean")),
                FileSnapshot::missing(),
            ),
        );

        store.update_material(&key, material("Dirty"));
        assert!(store.snapshot(&key).unwrap().dirty);
        assert_eq!(store.active_key(), Some(&key));

        store.update_material(&key, material("Clean"));
        assert!(!store.snapshot(&key).unwrap().dirty);
        assert_eq!(
            store.snapshot(&key).unwrap().state,
            AuthoredDocumentState::Clean
        );
        assert!(store
            .documents
            .get(&key)
            .is_some_and(|document| document.recovery_revision.is_none()));
    }

    #[test]
    fn returning_to_clean_marks_an_existing_recovery_for_cleanup() {
        let key = AuthoredAssetDocumentKey::new("material-id", AuthoredAssetDocumentKind::Material);
        let mut store = AuthoredAssetDocumentStore::default();
        store.documents.insert(
            key.clone(),
            AuthoredAssetDocument::new(
                key.clone(),
                "assets/materials/test.ron".into(),
                AuthoredAssetValue::Material(material("Clean")),
                FileSnapshot::missing(),
            ),
        );

        store.update_material(&key, material("Dirty"));
        let document = store.documents.get_mut(&key).unwrap();
        document.recovery_revision = Some(document.revision);
        store.update_material(&key, material("Clean"));

        assert_eq!(stale_recovery_keys(&store), vec![key]);
    }

    #[test]
    fn recovery_equal_to_authoritative_source_never_prompts() {
        let clean = AuthoredAssetValue::Material(material("Clean"));
        let dirty = AuthoredAssetValue::Material(material("Dirty"));

        assert!(!recovery_requires_prompt(Some(&clean), &clean));
        assert!(recovery_requires_prompt(Some(&clean), &dirty));
        assert!(recovery_requires_prompt(None, &dirty));
    }

    #[test]
    fn dirty_documents_survive_switching_active_context() {
        let first = AuthoredAssetDocumentKey::new("first", AuthoredAssetDocumentKind::Material);
        let second = AuthoredAssetDocumentKey::new("second", AuthoredAssetDocumentKind::Material);
        let mut store = AuthoredAssetDocumentStore::default();
        for key in [&first, &second] {
            store.documents.insert(
                key.clone(),
                AuthoredAssetDocument::new(
                    key.clone(),
                    format!("assets/{key:?}.ron"),
                    AuthoredAssetValue::Material(material("Clean")),
                    FileSnapshot::missing(),
                ),
            );
        }
        store.update_material(&first, material("First dirty"));
        store.update_material(&second, material("Second dirty"));

        assert_eq!(store.dirty_count(), 2);
        assert_eq!(store.active_key(), Some(&second));
    }

    #[test]
    fn registry_patch_updates_only_the_selected_record() {
        let first_id = AssetId::new();
        let second_id = AssetId::new();
        let mut document = AssetRegistryDocument {
            records: vec![
                AssetRecord {
                    id: first_id.clone(),
                    path: "assets/first.glb".into(),
                    label: "First".into(),
                    kind: AssetKind::Model,
                    source_fingerprint: None,
                    import_settings: ModelImportSettings::default().into(),
                    dependencies: Vec::new(),
                },
                AssetRecord {
                    id: second_id.clone(),
                    path: "assets/second.glb".into(),
                    label: "Second".into(),
                    kind: AssetKind::Model,
                    source_fingerprint: None,
                    import_settings: ModelImportSettings::default().into(),
                    dependencies: Vec::new(),
                },
            ],
            ..Default::default()
        };
        let changed = ModelImportSettings {
            scale: 2.0,
            ..Default::default()
        };
        apply_registry_value(
            &mut document,
            &AuthoredAssetDocumentKey::new(
                first_id.as_string(),
                AuthoredAssetDocumentKind::ModelImportSettings,
            ),
            &AuthoredAssetValue::ModelImportSettings(changed),
        )
        .unwrap();

        assert_eq!(document.records[0].model_import().scale, 2.0);
        assert_eq!(document.records[1].model_import().scale, 1.0);
    }
}
