//! Docked project material catalog, usage view, and editor entry point.

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

use bevy::prelude::*;
use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons;
use shared::{
    BrushDesc, MaterialAsset, MaterialDesc, MaterialInstanceAsset, SkinnedMeshRenderer,
    StaticMeshRenderer,
};

use crate::asset_db::{ensure_asset_record, AssetRegistry};
use crate::assets::{
    draw_asset_cell_with, invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection,
    AssetThumbnailCache, EditorAsset, EditorAssetKind, EditorAssets, ThumbnailCacheSnapshot,
};
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
use crate::scene_io::SceneIo;

use super::asset_browser::{create_material_instance_from_base, top_level_asset_details_panel};
use super::theme::{
    panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM,
    TEXT_MUTED, WIDGET_BG,
};
use super::widgets::{icon_button_small, phosphor_icon, tool_button};
use super::{DockTabRequest, EditorTab};

const MATERIALS_FOLDER: &str = "assets/materials";
const DETAILS_BREAKPOINT: f32 = 760.0;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MaterialLibraryFilter {
    All,
    Materials,
    Instances,
    Used,
    Unused,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MaterialLibraryView {
    Grid,
    List,
}

#[derive(Resource, Debug)]
pub struct MaterialLibraryState {
    search: String,
    filter: MaterialLibraryFilter,
    view: MaterialLibraryView,
    thumbnail_size: f32,
    show_details: bool,
}

impl Default for MaterialLibraryState {
    fn default() -> Self {
        Self {
            search: String::new(),
            filter: MaterialLibraryFilter::All,
            view: MaterialLibraryView::Grid,
            thumbnail_size: 72.0,
            show_details: true,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MaterialDocumentKind {
    Material,
    Instance,
    Invalid,
}

impl MaterialDocumentKind {
    fn short_label(self) -> &'static str {
        match self {
            Self::Material => "MAT",
            Self::Instance => "MI",
            Self::Invalid => "ERR",
        }
    }

    fn label(self) -> &'static str {
        match self {
            Self::Material => "Material",
            Self::Instance => "Material Instance",
            Self::Invalid => "Invalid Material",
        }
    }
}

#[derive(Debug, Clone)]
struct MaterialLibraryRow {
    asset: EditorAsset,
    selection: AssetSelection,
    kind: MaterialDocumentKind,
    scene_users: Vec<MaterialSceneUser>,
    dependent_instances: usize,
    error: Option<String>,
}

#[derive(Debug, Clone)]
struct MaterialSceneUser {
    entity: Entity,
    actor_label: String,
    detail: String,
}

#[derive(Debug, Clone)]
struct CachedMaterialDocument {
    asset: EditorAsset,
    selection: AssetSelection,
    kind: MaterialDocumentKind,
    base_path: Option<String>,
    error: Option<String>,
}

#[derive(Resource, Debug, Default)]
pub struct MaterialLibraryCatalog {
    documents: Vec<CachedMaterialDocument>,
}

#[derive(Debug, Clone)]
struct MaterialLibrarySnapshot {
    search: String,
    filter: MaterialLibraryFilter,
    view: MaterialLibraryView,
    thumbnail_size: f32,
    show_details: bool,
}

pub fn material_library_ui(
    world: &mut World,
    ui: &mut egui::Ui,
    selected_entities: &mut SelectedEntities,
) {
    let selected = world.resource::<EditorAssets>().selected.clone();
    let rows = material_rows(world);
    let visible_rows = {
        let state = world.resource::<MaterialLibraryState>();
        rows.iter()
            .filter(|row| row_matches(row, state))
            .cloned()
            .collect::<Vec<_>>()
    };
    let visible_assets = visible_rows
        .iter()
        .map(|row| row.asset.clone())
        .collect::<Vec<_>>();
    prefetch_asset_thumbnails(world, &visible_assets);
    let cache = world.resource::<AssetThumbnailCache>().snapshot();

    material_library_toolbar(world, ui, &rows, &selected);
    ui.separator();

    let state = {
        let state = world.resource::<MaterialLibraryState>();
        MaterialLibrarySnapshot {
            search: state.search.clone(),
            filter: state.filter,
            view: state.view,
            thumbnail_size: state.thumbnail_size,
            show_details: state.show_details,
        }
    };
    let selected_row = selected
        .as_ref()
        .and_then(|selection| rows.iter().find(|row| &row.selection == selection))
        .cloned();
    let wide = ui.available_width() >= DETAILS_BREAKPOINT;

    if !wide && state.show_details {
        if let Some(row) = selected_row.as_ref() {
            egui::ScrollArea::vertical()
                .id_salt("material_library_narrow_details")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    material_usage_ui(world, ui, row, selected_entities);
                    top_level_asset_details_panel(world, ui, selected_entities, &row.asset);
                });
            return;
        }
    }

    ui.horizontal(|ui| {
        let details_width = if wide && state.show_details {
            (ui.available_width() * 0.32).clamp(320.0, 430.0)
        } else {
            0.0
        };
        let catalog_width = (ui.available_width() - details_width - 10.0).max(1.0);
        ui.allocate_ui_with_layout(
            egui::vec2(catalog_width, ui.available_height()),
            egui::Layout::top_down(egui::Align::Min),
            |ui| {
                material_catalog_header(ui, &visible_rows, &rows, &state);
                egui::ScrollArea::vertical()
                    .id_salt("material_library_catalog")
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        if visible_rows.is_empty() {
                            ui.label(
                                egui::RichText::new("No materials match the current filters.")
                                    .color(TEXT_DIM),
                            );
                        } else if state.view == MaterialLibraryView::Grid {
                            material_grid(
                                world,
                                ui,
                                &visible_rows,
                                &selected,
                                &cache,
                                state.thumbnail_size,
                                selected_entities,
                            );
                        } else {
                            material_list(
                                world,
                                ui,
                                &visible_rows,
                                &selected,
                                &cache,
                                selected_entities,
                            );
                        }
                    });
            },
        );

        if details_width > 0.0 {
            ui.separator();
            ui.allocate_ui_with_layout(
                egui::vec2(details_width, ui.available_height()),
                egui::Layout::top_down(egui::Align::Min),
                |ui| {
                    egui::ScrollArea::vertical()
                        .id_salt("material_library_details")
                        .auto_shrink([false, false])
                        .show(ui, |ui| {
                            if let Some(row) = selected_row.as_ref() {
                                material_usage_ui(world, ui, row, selected_entities);
                                top_level_asset_details_panel(
                                    world,
                                    ui,
                                    selected_entities,
                                    &row.asset,
                                );
                            } else {
                                ui.label(panel_heading("Material Details"));
                                ui.separator();
                                ui.label(
                                    egui::RichText::new("No material selected").color(TEXT_DIM),
                                );
                            }
                        });
                },
            );
        }
    });
}

fn material_library_toolbar(
    world: &mut World,
    ui: &mut egui::Ui,
    rows: &[MaterialLibraryRow],
    selected: &Option<AssetSelection>,
) {
    let selected_base = selected.as_ref().and_then(|selection| {
        rows.iter()
            .find(|row| &row.selection == selection && row.kind == MaterialDocumentKind::Material)
            .map(|row| row.asset.clone())
    });
    let mut create_material = false;
    let mut create_instance = false;
    let mut reveal_in_assets = false;
    let mut refresh = false;
    ui.horizontal(|ui| {
        if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh materials").clicked() {
            refresh = true;
        }
        if ui
            .button(format!("{} Material", icons::PLUS.as_str()))
            .on_hover_text("Create Material")
            .clicked()
        {
            create_material = true;
        }
        if ui
            .add_enabled(
                selected_base.is_some(),
                egui::Button::new(format!("{} Instance", icons::COPY.as_str())),
            )
            .on_hover_text("Create Material Instance from selected Material")
            .clicked()
        {
            create_instance = true;
        }
        if icon_button_small(ui, icons::FOLDER_OPEN, "Show in Asset Browser").clicked() {
            reveal_in_assets = true;
        }

        ui.separator();
        let mut state = world.resource_mut::<MaterialLibraryState>();
        ui.label(phosphor_icon(icons::MAGNIFYING_GLASS, 14.0).color(TEXT_DIM));
        ui.add(
            egui::TextEdit::singleline(&mut state.search)
                .hint_text("Search materials...")
                .desired_width((ui.available_width() * 0.22).clamp(120.0, 220.0)),
        );
        ui.separator();
        for (filter, label) in [
            (MaterialLibraryFilter::All, "All"),
            (MaterialLibraryFilter::Materials, "Materials"),
            (MaterialLibraryFilter::Instances, "Instances"),
            (MaterialLibraryFilter::Used, "Used"),
            (MaterialLibraryFilter::Unused, "Unused"),
        ] {
            ui.selectable_value(&mut state.filter, filter, label);
        }
        ui.separator();
        if tool_button(
            ui,
            icons::GRID_FOUR,
            state.view == MaterialLibraryView::Grid,
            "Grid view",
        )
        .clicked()
        {
            state.view = MaterialLibraryView::Grid;
        }
        if tool_button(
            ui,
            icons::LIST,
            state.view == MaterialLibraryView::List,
            "List view",
        )
        .clicked()
        {
            state.view = MaterialLibraryView::List;
        }
        if tool_button(ui, icons::SLIDERS, state.show_details, "Material details").clicked() {
            state.show_details = !state.show_details;
        }
        if state.view == MaterialLibraryView::Grid && ui.available_width() > 120.0 {
            ui.add_sized(
                [88.0, 20.0],
                egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false),
            );
        }
    });

    if refresh {
        world.resource_mut::<EditorAssets>().refresh();
        invalidate_on_catalog_refresh(world);
    }
    if create_material {
        if let Err(error) = create_material_asset(world) {
            world.resource_mut::<SceneIo>().status = error;
        }
    }
    if create_instance {
        if let Some(asset) = selected_base {
            if let Some(path) = asset.path.as_deref() {
                create_material_instance_from_base(world, path, &asset.label);
            }
        }
    }
    if reveal_in_assets {
        if let Some(asset) = selected
            .as_ref()
            .and_then(|selection| rows.iter().find(|row| &row.selection == selection))
        {
            world.resource_mut::<EditorAssets>().current_folder = asset.asset.folder_path.clone();
            world.resource_mut::<DockTabRequest>().0 = Some(EditorTab::AssetBrowser);
        }
    }
}

fn material_catalog_header(
    ui: &mut egui::Ui,
    visible: &[MaterialLibraryRow],
    all: &[MaterialLibraryRow],
    state: &MaterialLibrarySnapshot,
) {
    ui.horizontal_wrapped(|ui| {
        ui.label(panel_heading("Project Materials"));
        ui.label(
            egui::RichText::new(format!("{} / {}", visible.len(), all.len()))
                .monospace()
                .color(TEXT_DIM),
        );
        if !state.search.trim().is_empty() {
            ui.label(egui::RichText::new(format!("\"{}\"", state.search)).color(ACCENT));
        }
        let filter = match state.filter {
            MaterialLibraryFilter::All => None,
            MaterialLibraryFilter::Materials => Some("Materials"),
            MaterialLibraryFilter::Instances => Some("Instances"),
            MaterialLibraryFilter::Used => Some("Used in scene"),
            MaterialLibraryFilter::Unused => Some("Unused in scene"),
        };
        if let Some(filter) = filter {
            ui.label(egui::RichText::new(filter).color(SELECTION).small());
        }
    });
}

fn material_grid(
    world: &mut World,
    ui: &mut egui::Ui,
    rows: &[MaterialLibraryRow],
    selected: &Option<AssetSelection>,
    cache: &ThumbnailCacheSnapshot,
    thumbnail_size: f32,
    selected_entities: &mut SelectedEntities,
) {
    let cell_width = thumbnail_size.clamp(48.0, 112.0) + 26.0;
    let columns = (ui.available_width() / cell_width).floor().max(1.0) as usize;
    for chunk in rows.chunks(columns) {
        ui.horizontal(|ui| {
            for row in chunk {
                let response = draw_asset_cell_with(
                    ui,
                    &row.asset,
                    cache.texture_for(&row.asset),
                    cache.is_pending(&row.asset),
                    cache.is_failed(&row.asset).then_some("failed"),
                    selected.as_ref() == Some(&row.selection),
                    thumbnail_size,
                );
                draw_material_grid_badges(ui, &response, row);
                handle_material_response(world, response, row, selected_entities);
            }
        });
    }
}

fn draw_material_grid_badges(ui: &egui::Ui, response: &egui::Response, row: &MaterialLibraryRow) {
    let kind_color = match row.kind {
        MaterialDocumentKind::Material => ACCENT,
        MaterialDocumentKind::Instance => SELECTION,
        MaterialDocumentKind::Invalid => ERROR,
    };
    let kind_rect = egui::Rect::from_min_size(
        response.rect.left_top() + egui::vec2(6.0, 6.0),
        egui::vec2(30.0, 17.0),
    );
    ui.painter().rect(
        kind_rect,
        3.0,
        ELEVATED_BG,
        egui::Stroke::new(1.0, kind_color),
        egui::StrokeKind::Inside,
    );
    ui.painter().text(
        kind_rect.center(),
        egui::Align2::CENTER_CENTER,
        row.kind.short_label(),
        egui::FontId::monospace(9.0),
        kind_color,
    );
    if !row.scene_users.is_empty() {
        let usage_rect = egui::Rect::from_min_size(
            response.rect.right_top() + egui::vec2(-35.0, 6.0),
            egui::vec2(29.0, 17.0),
        );
        ui.painter().rect(
            usage_rect,
            3.0,
            ELEVATED_BG,
            egui::Stroke::new(1.0, SUCCESS),
            egui::StrokeKind::Inside,
        );
        ui.painter().text(
            usage_rect.center(),
            egui::Align2::CENTER_CENTER,
            row.scene_users.len().to_string(),
            egui::FontId::monospace(9.0),
            SUCCESS,
        );
    }
}

fn material_list(
    world: &mut World,
    ui: &mut egui::Ui,
    rows: &[MaterialLibraryRow],
    selected: &Option<AssetSelection>,
    cache: &ThumbnailCacheSnapshot,
    selected_entities: &mut SelectedEntities,
) {
    for row in rows {
        let is_selected = selected.as_ref() == Some(&row.selection);
        let height = 42.0;
        let (rect, response) = ui.allocate_exact_size(
            egui::vec2(ui.available_width(), height),
            egui::Sense::click_and_drag(),
        );
        ui.painter().rect(
            rect,
            3.0,
            if is_selected {
                crate::ui::theme::SELECTION_BG_MUTED
            } else if response.hovered() {
                ELEVATED_BG
            } else {
                WIDGET_BG
            },
            egui::Stroke::new(1.0, if is_selected { SELECTION } else { BORDER }),
            egui::StrokeKind::Inside,
        );
        let thumb_rect = egui::Rect::from_min_size(
            rect.left_top() + egui::vec2(5.0, 5.0),
            egui::vec2(32.0, 32.0),
        );
        if let Some(texture) = cache.texture_for(&row.asset) {
            ui.painter().image(
                texture,
                thumb_rect,
                egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
                egui::Color32::WHITE,
            );
        } else {
            ui.painter().text(
                thumb_rect.center(),
                egui::Align2::CENTER_CENTER,
                icons::PALETTE.as_str(),
                egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-regular".into())),
                if row.error.is_some() { ERROR } else { TEXT },
            );
        }
        ui.painter().text(
            egui::pos2(44.0 + rect.left(), rect.center().y - 7.0),
            egui::Align2::LEFT_CENTER,
            row.asset.label.as_str(),
            egui::FontId::proportional(13.0),
            TEXT,
        );
        ui.painter().text(
            egui::pos2(44.0 + rect.left(), rect.center().y + 9.0),
            egui::Align2::LEFT_CENTER,
            row.asset.path.as_deref().unwrap_or(""),
            egui::FontId::monospace(10.0),
            TEXT_MUTED,
        );
        ui.painter().text(
            rect.right_center() - egui::vec2(10.0, 0.0),
            egui::Align2::RIGHT_CENTER,
            format!(
                "{}  |  {} scene  |  {} instances",
                row.kind.label(),
                row.scene_users.len(),
                row.dependent_instances
            ),
            egui::FontId::proportional(11.0),
            if row.error.is_some() { ERROR } else { TEXT_DIM },
        );
        handle_material_response(world, response, row, selected_entities);
    }
}

fn handle_material_response(
    world: &mut World,
    response: egui::Response,
    row: &MaterialLibraryRow,
    selected_entities: &mut SelectedEntities,
) {
    let response = response.on_hover_ui(|ui| {
        ui.strong(&row.asset.label);
        ui.label(row.kind.label());
        ui.monospace(row.asset.path.as_deref().unwrap_or(""));
        ui.label(format!("Scene references: {}", row.scene_users.len()));
        if row.dependent_instances > 0 {
            ui.label(format!("Derived instances: {}", row.dependent_instances));
        }
        if let Some(error) = row.error.as_ref() {
            ui.colored_label(ERROR, error);
        }
    });
    if response.clicked() {
        world
            .resource_mut::<EditorAssets>()
            .select(row.selection.clone());
    }
    if response.drag_started() && row.error.is_none() {
        world
            .resource_mut::<EditorAssets>()
            .start_drag(row.selection.clone());
    }
    response.context_menu(|ui| {
        if row.kind == MaterialDocumentKind::Material && ui.button("Create Instance").clicked() {
            if let Some(path) = row.asset.path.as_deref() {
                create_material_instance_from_base(world, path, &row.asset.label);
            }
            ui.close();
        }
        if !row.scene_users.is_empty() {
            ui.menu_button("Scene Users", |ui| {
                for user in &row.scene_users {
                    if ui
                        .button(format!("{} / {}", user.actor_label, user.detail))
                        .clicked()
                    {
                        select_scene_user(world, selected_entities, user.entity);
                        ui.close();
                    }
                }
            });
        }
        if ui.button("Show in Asset Browser").clicked() {
            world.resource_mut::<EditorAssets>().current_folder = row.asset.folder_path.clone();
            world
                .resource_mut::<EditorAssets>()
                .select(row.selection.clone());
            world.resource_mut::<DockTabRequest>().0 = Some(EditorTab::AssetBrowser);
            ui.close();
        }
    });
}

fn material_rows(world: &mut World) -> Vec<MaterialLibraryRow> {
    let usages = collect_scene_material_usage(world);
    let documents = world.resource::<MaterialLibraryCatalog>().documents.clone();
    let mut instance_dependencies = HashMap::<String, usize>::new();
    for document in &documents {
        if let Some(base_path) = document.base_path.as_deref() {
            *instance_dependencies
                .entry(base_path.to_string())
                .or_default() += 1;
        }
    }

    let mut rows = documents
        .into_iter()
        .map(|document| {
            let normalized = normalize_path(document.asset.path.as_deref().unwrap_or_default());
            MaterialLibraryRow {
                selection: document.selection,
                scene_users: usages.get(&normalized).cloned().unwrap_or_default(),
                dependent_instances: instance_dependencies
                    .get(&normalized)
                    .copied()
                    .unwrap_or_default(),
                asset: document.asset,
                kind: document.kind,
                error: document.error,
            }
        })
        .collect::<Vec<_>>();
    rows.sort_by(|left, right| {
        left.asset
            .label
            .to_ascii_lowercase()
            .cmp(&right.asset.label.to_ascii_lowercase())
            .then_with(|| left.asset.path.cmp(&right.asset.path))
    });
    rows
}

fn row_matches(row: &MaterialLibraryRow, state: &MaterialLibraryState) -> bool {
    let search = state.search.trim().to_ascii_lowercase();
    let search_matches = search.is_empty()
        || row.asset.label.to_ascii_lowercase().contains(&search)
        || row
            .asset
            .path
            .as_deref()
            .is_some_and(|path| path.to_ascii_lowercase().contains(&search));
    let filter_matches = match state.filter {
        MaterialLibraryFilter::All => true,
        MaterialLibraryFilter::Materials => row.kind == MaterialDocumentKind::Material,
        MaterialLibraryFilter::Instances => row.kind == MaterialDocumentKind::Instance,
        MaterialLibraryFilter::Used => !row.scene_users.is_empty(),
        MaterialLibraryFilter::Unused => row.scene_users.is_empty(),
    };
    search_matches && filter_matches
}

fn collect_scene_material_usage(world: &mut World) -> HashMap<String, Vec<MaterialSceneUser>> {
    let mut usages = HashMap::new();
    let mut static_renderers = world.query::<(Entity, &StaticMeshRenderer, Option<&Name>)>();
    for (entity, renderer, name) in static_renderers.iter(world) {
        for slot in &renderer.materials.slots {
            if let Some(path) = slot
                .effective_material()
                .and_then(|reference| reference.0.source_path.as_deref())
            {
                add_usage(
                    &mut usages,
                    path,
                    MaterialSceneUser {
                        entity,
                        actor_label: actor_label(entity, name),
                        detail: format!("Static / {}", slot.name),
                    },
                );
            }
        }
    }
    let mut skinned_renderers = world.query::<(Entity, &SkinnedMeshRenderer, Option<&Name>)>();
    for (entity, renderer, name) in skinned_renderers.iter(world) {
        for slot in &renderer.materials.slots {
            if let Some(path) = slot
                .effective_material()
                .and_then(|reference| reference.0.source_path.as_deref())
            {
                add_usage(
                    &mut usages,
                    path,
                    MaterialSceneUser {
                        entity,
                        actor_label: actor_label(entity, name),
                        detail: format!("Skinned / {}", slot.name),
                    },
                );
            }
        }
    }
    let mut materials = world.query::<(Entity, &MaterialDesc, Option<&Name>)>();
    for (entity, material, name) in materials.iter(world) {
        if let Some(path) = material.material_asset_path.as_deref() {
            add_usage(
                &mut usages,
                path,
                MaterialSceneUser {
                    entity,
                    actor_label: actor_label(entity, name),
                    detail: "Surface".into(),
                },
            );
        }
    }
    let mut brushes = world.query::<(Entity, &BrushDesc, Option<&Name>)>();
    for (entity, brush, name) in brushes.iter(world) {
        for face in &brush.faces {
            if let Some(path) = face
                .material
                .as_ref()
                .and_then(|reference| reference.source_path.as_deref())
            {
                add_usage(
                    &mut usages,
                    path,
                    MaterialSceneUser {
                        entity,
                        actor_label: actor_label(entity, name),
                        detail: face.id.0.clone(),
                    },
                );
            }
        }
    }
    usages
}

fn add_usage(
    usages: &mut HashMap<String, Vec<MaterialSceneUser>>,
    path: &str,
    user: MaterialSceneUser,
) {
    usages.entry(normalize_path(path)).or_default().push(user);
}

fn actor_label(entity: Entity, name: Option<&Name>) -> String {
    name.map(|name| name.as_str().to_string())
        .unwrap_or_else(|| format!("Actor {entity:?}"))
}

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

pub(super) fn refresh_material_library_catalog(
    assets: Res<EditorAssets>,
    mut catalog: ResMut<MaterialLibraryCatalog>,
) {
    if !assets.is_changed() && !catalog.documents.is_empty() {
        return;
    }
    catalog.documents = assets
        .assets
        .iter()
        .filter(|asset| matches!(asset.kind, EditorAssetKind::Material))
        .cloned()
        .map(|asset| {
            let path = asset.path.as_deref().unwrap_or_default();
            let instance = MaterialInstanceAsset::load_from_path(path).ok();
            let kind = if instance.is_some() {
                MaterialDocumentKind::Instance
            } else if MaterialAsset::load_from_path(path).is_ok() {
                MaterialDocumentKind::Material
            } else {
                MaterialDocumentKind::Invalid
            };
            let base_path = instance
                .as_ref()
                .and_then(|instance| instance.base.0.source_path.as_deref())
                .map(normalize_path);
            CachedMaterialDocument {
                selection: AssetSelection::from_asset(&asset),
                error: shared::load_resolved_material_from_path(path).err(),
                asset,
                kind,
                base_path,
            }
        })
        .collect();
}

fn material_usage_ui(
    world: &mut World,
    ui: &mut egui::Ui,
    row: &MaterialLibraryRow,
    selected_entities: &mut SelectedEntities,
) {
    ui.horizontal(|ui| {
        ui.label(panel_heading("Scene Usage"));
        ui.label(
            egui::RichText::new(row.scene_users.len().to_string())
                .monospace()
                .color(if row.scene_users.is_empty() {
                    TEXT_DIM
                } else {
                    SUCCESS
                }),
        );
    });
    for user in &row.scene_users {
        if ui
            .button(format!(
                "{}  {} / {}",
                icons::CROSSHAIR.as_str(),
                user.actor_label,
                user.detail
            ))
            .clicked()
        {
            select_scene_user(world, selected_entities, user.entity);
        }
    }
    ui.separator();
}

fn select_scene_user(world: &mut World, selected_entities: &mut SelectedEntities, entity: Entity) {
    if world.get_entity(entity).is_err() {
        return;
    }
    selected_entities.select_replace(entity);
    world.resource_mut::<crate::selection::SelectedEntity>().0 = Some(entity);
    super::selection_ops::focus_editor_camera_on_selection(world);
    world.resource_mut::<DockTabRequest>().0 = Some(EditorTab::Viewport);
}

fn create_material_asset(world: &mut World) -> Result<(), String> {
    let path = next_material_path(Path::new(MATERIALS_FOLDER));
    let label = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("new_material")
        .replace('_', " ")
        .split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            chars
                .next()
                .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
                .unwrap_or_default()
        })
        .collect::<Vec<_>>()
        .join(" ");
    let asset = MaterialAsset {
        schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
        label: label.clone(),
        shader: None,
        shader_ref: None,
        render_state: shared::MaterialRenderState::default(),
        material: MaterialDesc::default(),
    };
    let text = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
        .map_err(|error| format!("could not serialize new material: {error}"))?;
    publish_authored_file(
        world,
        &path,
        text.as_bytes(),
        &FileSnapshot::missing(),
        FileWriteIntent::Material,
    )?;
    let catalog_path = normalize_path(&path.to_string_lossy());
    {
        let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() else {
            return Err(format!(
                "Created {catalog_path}, but the asset registry is unavailable"
            ));
        };
        ensure_asset_record(&mut registry, catalog_path.clone(), label, "Material")?;
    }
    {
        let mut assets = world.resource_mut::<EditorAssets>();
        assets.refresh();
        assets.current_folder = MATERIALS_FOLDER.into();
        assets.select(AssetSelection::File(catalog_path.clone()));
    }
    invalidate_on_catalog_refresh(world);
    world.resource_mut::<SceneIo>().status = format!("Created material {catalog_path}");
    Ok(())
}

fn next_material_path(folder: &Path) -> PathBuf {
    for index in 1.. {
        let name = if index == 1 {
            "new_material.ron".to_string()
        } else {
            format!("new_material_{index}.ron")
        };
        let candidate = folder.join(name);
        if !candidate.exists() {
            return candidate;
        }
    }
    unreachable!("unbounded material filename search")
}

#[cfg(test)]
mod tests {
    use super::*;
    use shared::{
        ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot,
    };

    #[test]
    fn usage_counts_exact_authored_material_paths() {
        let material_path = "assets/materials/steel.ron";
        let mut world = World::new();
        world.spawn(StaticMeshRenderer {
            slots: Vec::new(),
            materials: RendererMaterialSet {
                slots: vec![RendererMaterialSlot {
                    id: ComponentInstanceId::new("slot:body"),
                    name: "Body".into(),
                    source_material: None,
                    material: Some(MaterialRef::new(
                        EditorAssetRef::new("steel", "material:source", "Steel")
                            .with_source_path(material_path),
                    )),
                }],
                orphaned_assignments: Vec::new(),
            },
        });
        world.spawn(MaterialDesc {
            material_asset_path: Some(material_path.into()),
            ..Default::default()
        });
        let mut brush = BrushDesc::cuboid(Vec3::ONE);
        brush.faces[0].material = Some(
            EditorAssetRef::new("steel", "material:source", "Steel")
                .with_source_path(material_path),
        );
        world.spawn(brush);

        let usage = collect_scene_material_usage(&mut world);

        assert_eq!(usage.get(material_path).map(Vec::len), Some(3));
    }

    #[test]
    fn filtering_distinguishes_instances_and_scene_usage() {
        let base = MaterialLibraryRow {
            asset: EditorAsset {
                label: "Steel".into(),
                path: Some("assets/materials/steel.ron".into()),
                folder_path: MATERIALS_FOLDER.into(),
                kind: EditorAssetKind::Material,
            },
            selection: AssetSelection::File("assets/materials/steel.ron".into()),
            kind: MaterialDocumentKind::Material,
            scene_users: vec![
                MaterialSceneUser {
                    entity: Entity::from_bits(1),
                    actor_label: "Crate".into(),
                    detail: "Body".into(),
                },
                MaterialSceneUser {
                    entity: Entity::from_bits(2),
                    actor_label: "Floor".into(),
                    detail: "Surface".into(),
                },
            ],
            dependent_instances: 1,
            error: None,
        };
        let mut state = MaterialLibraryState {
            filter: MaterialLibraryFilter::Used,
            ..Default::default()
        };
        assert!(row_matches(&base, &state));
        state.filter = MaterialLibraryFilter::Instances;
        assert!(!row_matches(&base, &state));
        state.search = "steel".into();
        state.filter = MaterialLibraryFilter::All;
        assert!(row_matches(&base, &state));
    }

    #[test]
    fn catalog_refresh_classifies_resolved_material_instances_once_assets_change() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-material-library-cache-{}",
            uuid::Uuid::new_v4()
        ));
        std::fs::create_dir_all(&root).unwrap();
        let base_path = root.join("base.ron");
        let instance_path = root.join("instance.ron");
        let base = MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Base".into(),
            shader: None,
            shader_ref: None,
            render_state: Default::default(),
            material: MaterialDesc::default(),
        };
        std::fs::write(
            &base_path,
            ron::ser::to_string_pretty(&base, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
        let instance = MaterialInstanceAsset {
            schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: "Instance".into(),
            base: MaterialRef::new(
                EditorAssetRef::new("base", "material:source", "Base")
                    .with_source_path(base_path.to_string_lossy().into_owned()),
            ),
            parameters: Vec::new(),
            textures: Vec::new(),
        };
        std::fs::write(
            &instance_path,
            ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
        let material_asset = |label: &str, path: &Path| EditorAsset {
            label: label.into(),
            path: Some(path.to_string_lossy().into_owned()),
            folder_path: root.to_string_lossy().into_owned(),
            kind: EditorAssetKind::Material,
        };
        let mut app = App::new();
        app.insert_resource(EditorAssets {
            folders: Vec::new(),
            assets: vec![
                material_asset("Base", &base_path),
                material_asset("Instance", &instance_path),
            ],
            current_folder: root.to_string_lossy().into_owned(),
            selected: None,
            dragging: None,
            status: String::new(),
        })
        .init_resource::<MaterialLibraryCatalog>()
        .add_systems(Update, refresh_material_library_catalog);

        app.update();

        let catalog = app.world().resource::<MaterialLibraryCatalog>();
        assert_eq!(catalog.documents.len(), 2);
        let cached_instance = catalog
            .documents
            .iter()
            .find(|document| document.kind == MaterialDocumentKind::Instance)
            .unwrap();
        assert_eq!(
            cached_instance.base_path.as_deref(),
            Some(normalize_path(&base_path.to_string_lossy()).as_str())
        );
        assert!(cached_instance.error.is_none());
        std::fs::remove_dir_all(root).unwrap();
    }
}
