//! `dialog` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).

#![allow(unused_imports)]
use super::util::*;
use crate::ui::window::block_palette::BlockPaletteMsg;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
    parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
    CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply;
use crate::scene::{
    self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
use acadrust::{EntityType as AcadEntityType, Handle};
use iced::time::Instant;
use iced::{mouse, Point, Task};


impl OpenCADStudio {
    pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
        // Default the format dropdown to the loaded file's own format — its
        // DWG-vs-DXF kind (from the extension) and its version (from the parsed
        // document) — so Save-As round-trips the format instead of silently
        // re-targeting it. A new/unsaved drawing has no source format, so it
        // uses the application-wide default chosen in Options (#529).
        self.save_dialog_format = if let Some(path) = &self.tabs[tab_idx].current_path {
            let document = &self.tabs[tab_idx].scene.document;
            let is_dxf = crate::io::source_is_dxf(Some(path), document);
            let version = if is_dxf {
                document.version
            } else {
                document.dwg_source_version.unwrap_or(document.version)
            };
            crate::io::format_for_version(version, is_dxf)
        } else {
            self.default_save_format.clone()
        };

        // Pre-fill the default file name from the current path or the tab name;
        // the destination folder comes from the native OS dialog that follows.
        if let Some(p) = &self.tabs[tab_idx].current_path.clone() {
            if let Some(name) = p.file_name() {
                self.save_dialog_filename = name.to_string_lossy().into_owned();
            }
        } else {
            let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
            self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
        }
        if self.tabs[tab_idx].recovery_save_as_required {
            let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
            let stem = std::path::Path::new(&self.save_dialog_filename)
                .file_stem()
                .map(|value| value.to_string_lossy().into_owned())
                .unwrap_or_else(|| self.tabs[tab_idx].tab_display_name());
            self.save_dialog_filename = format!("{stem}_recovered.{ext}");
        }
        self.aec_drop_acknowledged = false;
        self.active_modal = Some(crate::app::ModalKind::SaveDialog);
        Task::none()
    }


    pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
        self.aec_drop_acknowledged = false;
        if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
            self.active_modal = None;
            self.reset_modal_geometry();
        }
        Task::none()
    }


    pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
        self.active_modal = Some(crate::app::ModalKind::Unsaved);
        // The unsaved-changes prompt renders inside the main window, so bring
        // that window to the foreground — a close signal can arrive while the
        // app is backgrounded, leaving the prompt unseen behind other windows.
        // `gain_focus` alone is ignored by most Linux WMs (focus-stealing
        // prevention), so pair it with an urgency hint so the window is at
        // least flagged for attention when the compositor blocks the raise.
        match self.main_window {
            Some(id) => Task::batch([
                iced::window::gain_focus(id),
                iced::window::request_user_attention(
                    id,
                    Some(iced::window::UserAttention::Critical),
                ),
            ]),
            None => Task::none(),
        }
    }


    pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
        if self.active_modal == Some(crate::app::ModalKind::Unsaved) {
            self.active_modal = None;
            self.reset_modal_geometry();
        }
        Task::none()
    }


pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
                // Commands use `start_allowed`; other events need a drawing
                // and stay blocked on the Start page (#299, #388, #389).
                if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) {
                    self.ribbon.close_dropdown();
                    self.command_line
                        .push_info(crate::t!("No drawing open — use New or Open first.").as_ref());
                    return Task::none();
                }
                // Dismiss any open dropdown / collapsed-panel flyout on tool use,
                // and remember this tool as its panel's last-used one.
                self.ribbon.close_dropdown();
                self.ribbon.note_panel_tool(&tool_id);
                self.ribbon.activate_tool(&tool_id);
                match event {
                    ModuleEvent::Command(cmd) => {
                        let task = self.dispatch_command(&cmd);
                        // One-shot tools (view changes, clipboard, toggles,
                        // audits…) leave nothing running: no interactive
                        // command and no dialog. Their highlight would stick
                        // forever — turn it off now. Interactive commands and
                        // dialog owners keep theirs; the command end / modal
                        // close clears those. (#355)
                        let i = self.active_tab;
                        if self.tabs[i].active_cmd.is_none()
                            && self.active_modal.is_none()
                            && !self.tabs[i].pan_mode
                            && !self.tabs[i].orbit_mode
                            && !self.tabs[i].zoom_dynamic_mode
                        {
                            self.ribbon.deactivate_tool();
                        }
                        return task;
                    }
                    ModuleEvent::OpenFileDialog => {
                        self.command_line
                            .push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref());
                    }
                    ModuleEvent::ClearModels => {
                        let i = self.active_tab;
                        self.tabs[i].scene.clear();
                        self.tabs[i].properties = PropertiesPanel::empty();
                        self.command_line.push_output(crate::t!("Scene cleared.").as_ref());
                    }
                    ModuleEvent::SetVisualStyle(name) => {
                        use crate::modules::view::visual_style;
                        match visual_style::mode_for_keyword(&name) {
                            Some(mode) => return Task::done(Message::SetRenderMode(mode)),
                            None => {
                                // Name the styles that do exist, from the same
                                // list every other caller reads.
                                self.command_line.push_error(
                                    crate::tf!("Unknown visual style \"{name}\".").as_ref(),
                                );
                                self.command_line
                                    .push_info(visual_style::keyword_prompt());
                            }
                        }
                    }
                    ModuleEvent::ToggleLayers => {
                        return Task::done(Message::ToggleLayers);
                    }
                    ModuleEvent::PluginFileDialog {
                        command,
                        title,
                        filter_name,
                        extensions,
                    } => {
                        return Task::perform(
                            async move {
                                let exts: Vec<&str> =
                                    extensions.iter().map(|s| s.as_str()).collect();
                                let path = crate::sys::file_dialog()
                                    .set_title(title)
                                    .add_filter(filter_name, &exts)
                                    .add_filter("All Files", &["*"])
                                    .pick_file()
                                    .await
                                    .map(|h| crate::sys::handle_path(&h));
                                (command, path)
                            },
                            |(command, path)| Message::PluginFileDialogResult { command, path },
                        );
                    }
                }
                // Every non-Command event above is a one-shot (state toggle,
                // clear, dialog spawn) — nothing stays running to clear the
                // highlight later, so turn it off here. (#355)
                self.ribbon.deactivate_tool();
                Task::none()
    }

    pub(super) fn on_unsaved_dialog_discard(&mut self) -> Task<Message> {
                match self.pending_close.take() {
                    Some(crate::app::PendingClose::Tab(idx)) => {
                        let close_win = self.close_unsaved_dialog_window();
                        // Discarded — drop this tab's autosave recovery copy.
                        #[cfg(not(target_arch = "wasm32"))]
                        let _ = std::fs::remove_file(self.autosave_target(idx));
                        if self.tabs.len() == 1 {
                            self.tab_counter += 1;
                            self.tabs[0] =
                                crate::app::document::DocumentTab::new_drawing(self.tab_counter);
                            self.active_tab = 0;
                            self.apply_bg_default(0);
                        } else {
                            self.tabs.remove(idx);
                            if self.active_tab >= self.tabs.len() {
                                self.active_tab = self.tabs.len() - 1;
                            }
                        }
                        // The active tab is now a fresh blank or a
                        // different existing tab; sync ribbon chips so
                        // they don't keep showing the discarded tab's
                        // last selection. #21.
                        self.sync_ribbon_layers();
                        self.sync_ribbon_from_selection();
                        return Task::batch([close_win, self.continue_tab_close_queue()]);
                    }
                    Some(crate::app::PendingClose::Quit) => {
                        if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
                            self.tabs[idx].dirty = false;
                        }
                        if self.tabs.iter().any(|t| t.dirty) {
                            // More dirty tabs remain — keep window open.
                            self.pending_close = Some(crate::app::PendingClose::Quit);
                        } else {
                            let close_win = self.close_unsaved_dialog_window();
                            return Task::batch(vec![close_win, self.exit_app()]);
                        }
                    }
                    None => {}
                }
                Task::none()
    }

    pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> {
        #[cfg(not(target_arch = "wasm32"))]
        {
            let Some(pending) = self.pending_close.clone() else {
                return Task::none();
            };
            let (idx, continuation) = match pending {
                crate::app::PendingClose::Tab(idx) => {
                    (idx, crate::app::SaveContinuation::CloseTab)
                }
                crate::app::PendingClose::Quit => {
                    let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else {
                        self.pending_close = None;
                        return Task::batch([
                            self.close_unsaved_dialog_window(),
                            self.exit_app(),
                        ]);
                    };
                    (idx, crate::app::SaveContinuation::Quit)
                }
            };

            if self.active_save_jobs.contains_key(&self.tabs[idx].id) {
                self.command_line
                    .push_info(crate::t!("Save already running for this drawing.").as_ref());
                return Task::none();
            }

            if !self.tabs[idx].recovery_save_as_required {
                if let Some(path) = self.tabs[idx].current_path.clone() {
                    let version = self.tabs[idx].scene.document.version;
                    self.prepare_native_save(idx);
                    let close = self.close_unsaved_dialog_window();
                    let save = self.queue_native_save(
                        idx,
                        path,
                        version,
                        crate::app::SavePurpose::Manual,
                        continuation,
                        false,
                        true,
                    );
                    return Task::batch([close, save]);
                }
            }

            self.active_tab = idx;
            self.save_dialog_for_unsaved = true;
            let close = self.close_unsaved_dialog_window();
            let save = self.save_with_default_format(idx);
            return Task::batch([close, save]);
        }

        #[cfg(target_arch = "wasm32")]
        {
            match self.pending_close.take() {
                Some(crate::app::PendingClose::Tab(idx)) => {
                    self.pending_close = Some(crate::app::PendingClose::Tab(idx));
                    self.save_dialog_for_unsaved = true;
                    let close = self.close_unsaved_dialog_window();
                    let save = self.save_with_default_format(idx);
                    Task::batch([close, save])
                }
                Some(crate::app::PendingClose::Quit) => {
                    if let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) {
                        self.active_tab = idx;
                        self.pending_close = Some(crate::app::PendingClose::Quit);
                        self.save_dialog_for_unsaved = true;
                        let close = self.close_unsaved_dialog_window();
                        let save = self.save_with_default_format(idx);
                        Task::batch([close, save])
                    } else {
                        Task::batch([
                            self.close_unsaved_dialog_window(),
                            self.exit_app(),
                        ])
                    }
                }
                None => Task::none(),
            }
        }
    }

    /// Build a unique block name from a file stem: the stem itself, then
    /// "stem (2)", "stem (3)", … on collisions.
    fn block_name_from_file(&self, stem: &str) -> String {
        let i = self.active_tab;
        let base = stem.trim();
        if base.is_empty() {
            return self.unique_block_name("Block");
        }
        let doc = &self.tabs[i].scene.document;
        if doc.block_records.get(base).is_none() {
            return base.to_string();
        }
        let mut n = 2;
        loop {
            let name = format!("{base} ({n})");
            if doc.block_records.get(&name).is_none() {
                return name;
            }
            n += 1;
        }
    }

    /// Load an external DWG/DXF at `path` and define its model-space contents as
    /// one new block in the active drawing. Returns the new block's name, or an
    /// error message. Nested block definitions are imported first so nested
    /// INSERTs render (AutoCAD's "inserting a drawing imports its block defs").
    fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result<String, String> {
        let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?;
        let stem = path
            .file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| "Block".to_string());
        self.import_document_as_block(doc, stem)
    }

    /// Define one block in the active drawing from a loaded `CadDocument`'s
    /// model-space entities (base = the file's model-space insertion base).
    fn import_document_as_block(
        &mut self,
        doc: acadrust::CadDocument,
        stem: String,
    ) -> Result<String, String> {
        let i = self.active_tab;
        // Model-space block record handle (Layout object first, name fallback).
        let model_br = doc
            .objects
            .values()
            .find_map(|o| {
                if let acadrust::objects::ObjectType::Layout(l) = o {
                    (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
                } else {
                    None
                }
            })
            .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
            .unwrap_or(acadrust::Handle::NULL);
        let mut entities: Vec<acadrust::EntityType> = if model_br.is_null() {
            Vec::new()
        } else {
            let br = doc.block_records.iter().find(|br| br.handle == model_br);
            let handles = br.map(|b| b.entity_handles.clone()).unwrap_or_default();
            if !handles.is_empty() {
                // Authoritative ownership list (DWG and well-formed DXF).
                handles
                    .iter()
                    .filter_map(|h| doc.get_entity(*h))
                    .filter(|e| {
                        !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_))
                    })
                    .cloned()
                    .collect()
            } else {
                // Legacy DXF that omits 330 group codes: treat null-owner
                // entities as model-space content (mirrors belongs_to_visible_block).
                doc.entities()
                    .filter(|e| {
                        let o = e.common().owner_handle;
                        o == model_br || o.is_null()
                    })
                    .filter(|e| {
                        !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_))
                    })
                    .cloned()
                    .collect()
            }
        };
        if entities.is_empty() {
            return Err("No model-space entities in that file.".to_string());
        }
        let base = glam::DVec3::new(
            doc.header.model_space_insertion_base.x,
            doc.header.model_space_insertion_base.y,
            doc.header.model_space_insertion_base.z,
        );
        let name = self.block_name_from_file(&stem);
        // Capture every table record needed by the top-level entities and their
        // nested block definitions. Importing only the definitions leaves
        // source-only layers, linetypes, and text/dimension styles dangling.
        let deps = crate::app::ClipboardDeps::capture(&doc, &entities);
        // Capture the imported file's nested block definitions once. When a
        // name collides with a block already in the active drawing, we must
        // *preserve both*: keep the destination's block and import the file's
        // under a unique name, then re-point every INSERT at the renamed one —
        // otherwise a nested reference silently resolves to the destination's
        // unrelated definition. (#135-style collision, but for file imports.)
        let mut defs = deps.blocks.clone();
        let mut rename_map: std::collections::HashMap<String, String> =
            std::collections::HashMap::new();
        // Reserve every destination block name plus every imported dependency
        // name, so importing a source file that itself holds "Door" and
        // "Door (2)" cannot generate a colliding second "Door (2)".
        let mut reserved: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
        for existing in self.tabs[i].scene.document.block_records.names() {
            reserved.insert(existing.to_string());
        }
        for def in &defs {
            reserved.insert(def.name.clone());
        }
        for def in &defs {
            // Keep the name only if it is truly unused in the active drawing.
            let used_in_dest = self.tabs[i]
                .scene
                .document
                .block_records
                .get(&def.name)
                .is_some();
            let dest_name = if used_in_dest {
                let mut n = 2;
                loop {
                    let candidate = format!("{} ({})", def.name, n);
                    if reserved.insert(candidate.clone()) {
                        break candidate;
                    }
                    n += 1;
                }
            } else {
                reserved.insert(def.name.clone());
                def.name.clone()
            };
            rename_map.insert(def.name.clone(), dest_name);
        }
        // Rewrite every INSERT in the model-space entities and in every captured
        // definition, then rename the definitions and define them.
        for entity in entities
            .iter_mut()
            .chain(defs.iter_mut().flat_map(|def| def.entities.iter_mut()))
        {
            if let acadrust::EntityType::Insert(ins) = entity {
                if let Some(new_name) = rename_map.get(&ins.block_name) {
                    ins.block_name = new_name.clone();
                }
            }
        }
        for def in &mut defs {
            if let Some(new_name) = rename_map.get(&def.name) {
                def.name = new_name.clone();
            }
        }
        // Every mutation below belongs to the one INSERT FILE undo step.
        self.push_undo_snapshot(i, "INSERT FILE");
        self.merge_dependencies(i, &deps);
        for def in defs {
            self.tabs[i]
                .scene
                .define_block_raw(&def.name, def.base_point, def.entities);
        }
        self.tabs[i]
            .scene
            .define_block_from_owned_entities(entities, &name, base)?;
        self.tabs[i].scene.populate_meshes_from_document();
        self.tabs[i].dirty = true;
        Ok(name)
    }

    pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task<Message> {
        use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg};
        match m {
            BlockPaletteMsg::Search(s) => {
                self.block_palette.search = s;
                iced::Task::none()
            }
            BlockPaletteMsg::CyclePreviewSize => {
                self.block_palette.preview_size =
                    crate::ui::window::block_palette::cycle_preview_size(
                        self.block_palette.preview_size,
                    );
                iced::Task::none()
            }
            BlockPaletteMsg::Refresh => {
                self.refresh_block_palette();
                iced::Task::none()
            }
            BlockPaletteMsg::PickFile => iced::Task::perform(
                async {
                    let handle = rfd::AsyncFileDialog::new()
                        .set_title("Select Drawing to Insert as Block")
                        .add_filter("DWG/DXF Files", &["dwg", "dxf", "DWG", "DXF"])
                        .pick_file()
                        .await;
                    match handle {
                        Some(h) => Ok(crate::sys::handle_path(&h)),
                        None => Err("Cancelled".to_string()),
                    }
                },
                |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)),
            ),
            BlockPaletteMsg::FilePicked(Ok(path)) => {
                match self.import_file_as_block(path) {
                    Ok(name) => {
                        self.command_line
                            .push_output(&format!("Inserting \"{name}\" from file."));
                        self.refresh_block_palette();
                        self.start_block_placement(&name);
                    }
                    Err(e) if e != "Cancelled" => {
                        self.command_line.push_error(&format!("INSERT FILE: {e}"));
                    }
                    Err(_) => {}
                }
                iced::Task::none()
            }
            BlockPaletteMsg::FilePicked(Err(e)) => {
                if e != "Cancelled" {
                    self.command_line.push_error(&e);
                }
                iced::Task::none()
            }
            BlockPaletteMsg::Insert(name) => {
                self.start_block_placement(&name);
                iced::Task::none()
            }
        }
    }

    /// Dock chrome interaction (grab / pin / resize / hover / move) applied to
    /// whichever panel the message names.
    pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
        use crate::app::config::DockSide;
        use crate::ui::dock::{DockMsg, PanelId};
        match m {
            DockMsg::DockGrab(id) => {
                self.dock_dragging = Some(id);
                self.dock_resizing = None;
                self.dock_drag_last = None;
                self.dock_drag_target = self.dock.location(id);
                self.dock_expanded = Some(id);
                iced::Task::none()
            }
            DockMsg::ResizeGrab(id) => {
                self.dock_resizing = Some(id);
                self.dock_dragging = None;
                self.dock_drag_last = None;
                self.dock_drag_target = None;
                self.dock_expanded = Some(id);
                iced::Task::none()
            }
            DockMsg::WidthReset(id) => {
                self.dock.reset_width(id);
                self.save_config();
                iced::Task::none()
            }
            DockMsg::AutoCollapseToggle(id) => {
                let on = !self.dock.auto_collapse(id);
                self.dock.set_auto_collapse(id, on);
                self.dock_expanded = if on { None } else { Some(id) };
                self.save_config();
                iced::Task::none()
            }
            DockMsg::Close(id) => {
                match id {
                    PanelId::BlockPalette => {
                        self.show_block_palette = false;
                        self.block_palette.placing = None;
                    }
                    PanelId::Properties => {
                        self.show_properties = false;
                        self.ribbon.set_properties(false);
                    }
                }
                if self.dock_expanded == Some(id) {
                    self.dock_expanded = None;
                }
                if self.dock_dragging == Some(id) {
                    self.dock_dragging = None;
                }
                if self.dock_resizing == Some(id) {
                    self.dock_resizing = None;
                }
                iced::Task::none()
            }
            DockMsg::Hover(id) => {
                // Ignored while dragging/resizing: the pointer is over the
                // drag preview, not a rail, so a hover must not collapse the
                // panel being dragged or repoint the resize target.
                if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
                    self.dock_expanded = Some(id);
                }
                iced::Task::none()
            }
            DockMsg::HoverExit => {
                if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
                    if let Some(id) = self.dock_expanded {
                        if self.dock.auto_collapse(id) {
                            self.dock_expanded = None;
                        }
                    }
                }
                iced::Task::none()
            }
            DockMsg::DragMove(point) => {
                if self.dock_dragging.is_some() {
                    let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1;
                    let side = if point.x < self.win_size.0 * 0.5 {
                        DockSide::Left
                    } else {
                        DockSide::Right
                    };
                    let index = crate::ui::dock::drop_index(
                        point.y,
                        std::cmp::max(self.dock_visible_len(side), 1),
                        avail,
                    );
                    self.dock_drag_target = Some((side, index));
                } else if let Some(id) = self.dock_resizing {
                    if let Some(last) = self.dock_drag_last {
                        let dx = point.x - last.x;
                        let delta = match self.dock.location(id) {
                            Some((DockSide::Left, _)) => dx,
                            _ => -dx,
                        };
                        let cur = self.dock.settings(id).width + delta;
                        self.dock.set_width(id, cur);
                    }
                }
                if self.dock_dragging.is_some() || self.dock_resizing.is_some() {
                    self.dock_drag_last = Some(point);
                }
                iced::Task::none()
            }
            DockMsg::DragRelease => {
                if let Some(id) = self.dock_dragging {
                    if let Some((side, index)) = self.dock_drag_target {
                        if self.dock.dock(id, side, index) {
                            self.save_config();
                        }
                    }
                }
                self.dock_dragging = None;
                self.dock_resizing = None;
                self.dock_drag_last = None;
                self.dock_drag_target = None;
                iced::Task::none()
            }
        }
    }

    /// Whether `id` is currently rendered (not closed, not on the start screen /
    /// clean-screen viewport). Mirrors the visibility filter the edge column
    /// renderer applies before building each side's stack.
    pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
        use crate::ui::dock::PanelId;
        if self.tabs[self.active_tab].is_start || self.clean_screen {
            return false;
        }
        match id {
            PanelId::Properties => self.show_properties,
            PanelId::BlockPalette => self.show_block_palette,
        }
    }

    /// Number of panels currently rendered on `side`. Closed panels keep their
    /// stack slot but take no vertical space, so drag preview geometry must
    /// size slots against only the panels that are actually visible.
    pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
        let ids: &[crate::ui::dock::PanelId] = match side {
            crate::app::config::DockSide::Left => &self.dock.left,
            crate::app::config::DockSide::Right => &self.dock.right,
        };
        ids.iter()
            .filter(|id| self.dock_panel_visible(**id))
            .count()
    }

    /// Start placing `name` through the INSERT command, skipping the name prompt.
    fn start_block_placement(&mut self, name: &str) {
        let i = self.active_tab;
        let wires = self
            .block_palette
            .blocks
            .iter()
            .find(|b| b.name == name)
            .map(|b| b.wires.clone())
            .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name));
        use crate::modules::insert::insert_block::InsertBlockCommand;
        let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO);
        use crate::command::CadCommand;
        self.command_line.push_info(&cmd.prompt());
        self.tabs[i].active_cmd = Some(Box::new(cmd));
        self.block_palette.placing = Some(name.to_string());
    }

    /// Rebuild the panel's block list + cached wires from the active drawing.
    pub(crate) fn refresh_block_palette(&mut self) {
        let i = self.active_tab;
        let names = self.tabs[i].scene.custom_block_names();
        self.block_palette.cached_names = names.clone();
        self.block_palette.source_tab_id = Some(self.tabs[i].id);
        self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch;
        self.block_palette.blocks = names
            .into_iter()
            .map(|name| {
                let wires = self.tabs[i].scene.block_preview_wires(&name);
                crate::ui::window::block_palette::BlockEntry { name, wires }
            })
            .collect();
    }

    /// Cheap per-update check: rebuild when the active drawing's definitions
    /// changed, even when their names happen to stay the same.
    pub(crate) fn refresh_block_palette_if_stale(&mut self) {
        if !self.show_block_palette {
            return;
        }
        let i = self.active_tab;
        // `block_epoch` advances for every block-definition change, so it
        // avoids re-scanning every block name on unrelated application updates.
        if self.block_palette.source_tab_id != Some(self.tabs[i].id)
            || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch
        {
            self.refresh_block_palette();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::OpenCADStudio;
    use acadrust::entities::Line;
    use acadrust::types::Vector3;
    use acadrust::EntityType;

    fn fresh() -> OpenCADStudio {
        let mut app = OpenCADStudio::new_for_test();
        app.automation_op(r#"{"op":"new"}"#);
        app
    }

    /// A foreign document: the fresh scene's document plus one model-space LINE.
    fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
        let mut doc = app.tabs[app.active_tab].scene.document.clone();
        let model_br = doc
            .objects
            .values()
            .find_map(|o| {
                if let acadrust::objects::ObjectType::Layout(l) = o {
                    (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
                } else {
                    None
                }
            })
            .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
            .expect("fresh document has a model-space block record");
        let mut line = Line::new();
        line.common.owner_handle = model_br;
        line.start = Vector3::new(0.0, 0.0, 0.0);
        line.end = Vector3::new(100.0, 50.0, 0.0);
        doc.add_entity(EntityType::Line(line)).unwrap();
        doc
    }

    #[test]
    fn import_document_as_block_defines_block() {
        let mut app = fresh();
        let doc = foreign_doc(&app);
        let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap();
        assert_eq!(name, "Fixture");
        assert!(app.tabs[app.active_tab]
            .scene
            .document
            .block_records
            .get("Fixture")
            .is_some());
    }

    #[test]
    fn import_document_as_block_merges_source_only_layer() {
        use acadrust::tables::Layer;

        let mut app = fresh();
        let mut doc = foreign_doc(&app);
        let mut layer = Layer::new("Imported Fixtures");
        layer.handle = doc.allocate_handle();
        doc.layers.add(layer).unwrap();
        let model = doc
            .objects
            .values()
            .find_map(|o| match o {
                acadrust::objects::ObjectType::Layout(l) if l.name == "Model" => {
                    Some(l.block_record)
                }
                _ => None,
            })
            .unwrap();
        let line = doc
            .entities_mut()
            .find(|entity| entity.common().owner_handle == model)
            .unwrap();
        line.common_mut().layer = "Imported Fixtures".to_string();

        app.import_document_as_block(doc, "Fixture".to_string())
            .unwrap();
        assert!(app.tabs[app.active_tab]
            .scene
            .document
            .layers
            .contains("Imported Fixtures"));
    }

    /// A foreign document whose model space INSERTs a `Fixture` block, whose
    /// definition itself INSERTs a `Door` block — so the imported `Door` is a
    /// *nested dependency*, not a top-level entity. The `Door` in this file is
    /// unrelated to any `Door` in the destination drawing.
    fn nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
        use acadrust::entities::Insert;
        use acadrust::tables::BlockRecord;
        use acadrust::Handle;
        let mut doc = foreign_doc(app);

        // "Door" block definition: one LINE of geometry.
        let door_h = Handle::new(doc.next_handle());
        let mut door_br = BlockRecord::new("Door");
        door_br.handle = door_h;
        doc.block_records.add(door_br).unwrap();
        let mut door_line = Line::new();
        door_line.start = Vector3::new(0.0, 0.0, 0.0);
        door_line.end = Vector3::new(5.0, 0.0, 0.0);
        door_line.common.owner_handle = door_h;
        doc.add_entity(EntityType::Line(door_line)).unwrap();

        // "Fixture" block definition: INSERTs the nested "Door".
        let fixture_h = Handle::new(doc.next_handle());
        let mut fixture_br = BlockRecord::new("Fixture");
        fixture_br.handle = fixture_h;
        doc.block_records.add(fixture_br).unwrap();
        let mut nested = Insert::new("Door", Vector3::new(2.0, 0.0, 0.0));
        nested.common.owner_handle = fixture_h;
        doc.add_entity(EntityType::Insert(nested)).unwrap();

        // Model space references the fixture so the import captures it.
        let model_br = doc
            .objects
            .values()
            .find_map(|o| {
                if let acadrust::objects::ObjectType::Layout(l) = o {
                    (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
                } else {
                    None
                }
            })
            .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
            .expect("fresh document has a model-space block record");
        let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0));
        top.common.owner_handle = model_br;
        doc.add_entity(EntityType::Insert(top)).unwrap();

        doc
    }

    /// The nested-INSERT's block name inside a defined block record.
    fn nested_insert_target(
        doc: &acadrust::CadDocument,
        block: &str,
    ) -> Option<String> {
        let br = doc.block_records.get(block)?;
        br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? {
            EntityType::Insert(ins) => Some(ins.block_name.clone()),
            _ => None,
        })
    }

    /// A foreign document whose model space INSERTs a `Fixture` block whose
    /// definition INSERTs `Door (2)`, whose definition in turn INSERTs `Door`.
    /// The file therefore carries *both* `Door` and `Door (2)` as nested deps.
    fn doubly_nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
        use acadrust::entities::Insert;
        use acadrust::tables::BlockRecord;
        use acadrust::Handle;
        let mut doc = foreign_doc(app);

        let door_h = Handle::new(doc.next_handle());
        let mut door_br = BlockRecord::new("Door");
        door_br.handle = door_h;
        doc.block_records.add(door_br).unwrap();
        let mut door_line = Line::new();
        door_line.start = Vector3::new(0.0, 0.0, 0.0);
        door_line.end = Vector3::new(5.0, 0.0, 0.0);
        door_line.common.owner_handle = door_h;
        doc.add_entity(EntityType::Line(door_line)).unwrap();

        let door2_h = Handle::new(doc.next_handle());
        let mut door2_br = BlockRecord::new("Door (2)");
        door2_br.handle = door2_h;
        doc.block_records.add(door2_br).unwrap();
        let mut mid = Insert::new("Door", Vector3::ZERO);
        mid.common.owner_handle = door2_h;
        doc.add_entity(EntityType::Insert(mid)).unwrap();

        let fixture_h = Handle::new(doc.next_handle());
        let mut fixture_br = BlockRecord::new("Fixture");
        fixture_br.handle = fixture_h;
        doc.block_records.add(fixture_br).unwrap();
        let mut nested = Insert::new("Door (2)", Vector3::new(2.0, 0.0, 0.0));
        nested.common.owner_handle = fixture_h;
        doc.add_entity(EntityType::Insert(nested)).unwrap();

        let model_br = doc
            .objects
            .values()
            .find_map(|o| {
                if let acadrust::objects::ObjectType::Layout(l) = o {
                    (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
                } else {
                    None
                }
            })
            .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
            .expect("fresh document has a model-space block record");
        let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0));
        top.common.owner_handle = model_br;
        doc.add_entity(EntityType::Insert(top)).unwrap();

        doc
    }

    #[test]
    fn import_reserves_source_names_so_nested_deps_cannot_collide() {
        let mut app = fresh();
        let i = app.active_tab;
        // Source file itself carries "Door" and "Door (2)"; destination already
        // has "Door". The imported "Door" must land on "Door (3)" — NOT steal
        // "Door (2)", which the source file reserves for its own definition.
        let doc = doubly_nested_foreign_doc(&app);
        let mut dest_door = Line::new();
        dest_door.start = Vector3::new(0.0, 0.0, 0.0);
        dest_door.end = Vector3::new(3.0, 3.0, 0.0);
        app.tabs[i]
            .scene
            .define_block_from_owned_entities(
                vec![EntityType::Line(dest_door)],
                "Door",
                glam::DVec3::ZERO,
            )
            .unwrap();

        let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap();
        let doc = &app.tabs[i].scene.document;
        // The file's own "Door (2)" is kept intact and targets the file's
        // renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source).
        assert_eq!(
            nested_insert_target(doc, "Door (2)"),
            Some("Door (3)".to_string())
        );
        // The file's plain "Door" got bumped to "Door (3)" so both stay distinct.
        assert!(
            doc.block_records.get("Door").is_some(),
            "destination Door preserved"
        );
        assert!(
            doc.block_records.get("Door (2)").is_some(),
            "source Door (2) preserved under its own name"
        );
        assert!(
            doc.block_records.get("Door (3)").is_some(),
            "source Door renamed to Door (3), not Door (2)"
        );
        assert_eq!(
            nested_insert_target(doc, "Fixture"),
            Some("Door (2)".to_string())
        );
    }

    #[test]
    fn import_nested_block_collision_preserves_both_definitions() {
        let mut app = fresh();
        let i = app.active_tab;
        // Build the foreign document from the pristine scene first, so its
        // nested "Door" is genuinely distinct from the destination's.
        let doc = nested_foreign_doc(&app);
        // Destination drawing already has its own, unrelated "Door" block.
        let mut dest_door = Line::new();
        dest_door.start = Vector3::new(0.0, 0.0, 0.0);
        dest_door.end = Vector3::new(3.0, 3.0, 0.0);
        app.tabs[i]
            .scene
            .define_block_from_owned_entities(
                vec![EntityType::Line(dest_door)],
                "Door",
                glam::DVec3::ZERO,
            )
            .unwrap();

        let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap();
        assert_eq!(name, "Imported");

        let doc = &app.tabs[i].scene.document;
        // Both definitions survive: the destination's original and the imported one.
        assert!(
            doc.block_records.get("Door").is_some(),
            "destination's own Door must be preserved"
        );
        assert!(
            doc.block_records.get("Door (2)").is_some(),
            "imported nested Door must be renamed to Door (2)"
        );
        // The imported Fixture's nested INSERT must point at the imported Door (2),
        // not silently resolve to the destination's unrelated Door.
        assert_eq!(
            nested_insert_target(doc, "Fixture"),
            Some("Door (2)".to_string()),
            "Fixture must reference the renamed imported Door, not the destination's"
        );
        assert_eq!(
            nested_insert_target(doc, "Door (2)"),
            None,
            "imported Door (2) has no nested INSERTs"
        );
    }

    #[test]
    fn blockpalette_refresh_lists_and_places_block() {
        let mut app = fresh();
        let doc = foreign_doc(&app);
        let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap();
        app.refresh_block_palette();
        assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture"));
        app.start_block_placement(&name);
        let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running");
        assert_eq!(cmd.name(), "INSERT");
        assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture"));
    }

    #[test]
    fn blockpalette_reflects_new_block_without_reopen() {
        use acadrust::types::Transform;

        let mut app = fresh();
        let i = app.active_tab;

        // Reproduce the user-facing flow: select entities and run the BLOCK
        // command, which goes through `create_block_from_entities` (not the
        // clipboard / paste-as-block path).
        let mut line = Line::new();
        line.start = Vector3::ZERO;
        line.end = Vector3::new(10.0, 0.0, 0.0);
        let first = app.tabs[i].scene.add_entity(EntityType::Line(line));
        app.tabs[i].scene.select_entity(first, false);
        app.show_block_palette = true;
        let ws = Transform::identity();
        let id = Transform::identity();
        app.tabs[i]
            .scene
            .create_block_from_entities(&[first], "First", &ws, &id)
            .unwrap();
        app.refresh_block_palette_if_stale();
        assert!(app.block_palette.blocks.iter().any(|b| b.name == "First"));

        // Create a second block on the same tab, then re-run the per-update
        // stale check. It must pick up the new block WITHOUT reopening.
        line = Line::new();
        line.start = Vector3::ZERO;
        line.end = Vector3::new(9.0, 0.0, 0.0);
        let second = app.tabs[i].scene.add_entity(EntityType::Line(line));
        app.tabs[i].scene.select_entity(second, false);
        app.tabs[i]
            .scene
            .create_block_from_entities(&[second], "Second", &ws, &id)
            .unwrap();
        app.refresh_block_palette_if_stale();
        assert!(
            app.block_palette.blocks.iter().any(|b| b.name == "Second"),
            "Second must appear without reopening the panel"
        );
    }

    #[test]
    fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
        let mut app = fresh();
        app.show_block_palette = true;
        let id = crate::ui::dock::PanelId::BlockPalette;
        let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
        assert!(app.dock.auto_collapse(id), "pin enables auto-collapse");
        let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
        assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse");
        let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
        assert!(!app.show_block_palette, "close dismisses the sidebar");
    }

    #[test]
    fn blockpalette_dock_moves_to_other_side_and_persists() {
        let mut app = fresh();
        // Tests load the user's persisted config; reset the dock to a known
        // state so this stays hermetic.
        app.dock = Default::default();
        let id = crate::ui::dock::PanelId::BlockPalette;
        assert_eq!(
            app.dock.location(id),
            Some((crate::app::config::DockSide::Right, 0))
        );
        let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
        app.win_size = (1600.0, 900.0).into();
        let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0)));
        assert_eq!(
            app.dock_drag_target,
            Some((crate::app::config::DockSide::Left, 0))
        );
        let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease);
        assert_eq!(
            app.dock.location(id),
            Some((crate::app::config::DockSide::Left, 0))
        );
    }

    #[test]
    fn dock_visible_len_counts_only_rendered_panels() {
        let mut app = fresh();
        app.dock = Default::default();
        app.dock.left = vec![
            crate::ui::dock::PanelId::BlockPalette,
            crate::ui::dock::PanelId::Properties,
        ];
        // Left: block palette hidden, properties shown -> 1 visible panel.
        app.show_block_palette = false;
        app.show_properties = true;
        assert_eq!(
            app.dock_visible_len(crate::app::config::DockSide::Left),
            1
        );
        // Reveal the block palette -> both count.
        app.show_block_palette = true;
        assert_eq!(
            app.dock_visible_len(crate::app::config::DockSide::Left),
            2
        );
        // A hidden (closed) panel counts for nothing even when stacked.
        app.show_block_palette = false;
        assert_eq!(
            app.dock_visible_len(crate::app::config::DockSide::Left),
            1
        );
    }

    #[test]
    fn dock_drag_target_ignores_hidden_panels_on_the_side() {
        // Reproduce the persisted layout that exposed a half-height ghost: two
        // panels live in the left stack but the block palette is hidden
        // (show_block_palette=false), so only Properties renders. Drag geometry
        // must count only panels that are actually visible.
        let mut app = fresh();
        app.dock = Default::default();
        app.dock.left = vec![
            crate::ui::dock::PanelId::BlockPalette,
            crate::ui::dock::PanelId::Properties,
        ];
        app.show_block_palette = false;
        app.show_properties = true;
        let i = app.active_tab;
        app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
        app.win_size = (1600.0, 900.0).into();
        let id = crate::ui::dock::PanelId::Properties;
        let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
        // Pointer near the bottom of the left edge: one visible panel means a
        // single slot, so every y maps to index 0 (no top/bottom split).
        let _ =
            app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
        assert_eq!(
            app.dock_drag_target,
            Some((crate::app::config::DockSide::Left, 0))
        );
    }

    #[test]
    fn dock_drag_target_counts_all_visible_panels() {
        let mut app = fresh();
        app.dock = Default::default();
        app.dock.left = vec![
            crate::ui::dock::PanelId::BlockPalette,
            crate::ui::dock::PanelId::Properties,
        ];
        // Both panels shown: two real slots on the left edge. A pointer near
        // the bottom maps to the append slot (index == 2).
        app.show_block_palette = true;
        app.show_properties = true;
        let i = app.active_tab;
        app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
        app.win_size = (1600.0, 900.0).into();
        let id = crate::ui::dock::PanelId::Properties;
        let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
        let _ =
            app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
        assert_eq!(
            app.dock_drag_target,
            Some((crate::app::config::DockSide::Left, 2))
        );
    }

    #[test]
    fn blockpalette_width_reset() {
        let mut app = fresh();
        let id = crate::ui::dock::PanelId::BlockPalette;
        app.dock.set_width(id, 500.0);
        let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
        assert_eq!(app.dock.settings(id).width, 260.0);
    }

    #[test]
    fn block_name_from_file_avoids_collisions() {
        let mut app = fresh();
        let i = app.active_tab;
        let mut line = Line::new();
        line.start = Vector3::ZERO;
        line.end = Vector3::new(1.0, 0.0, 0.0);
        app.tabs[i]
            .scene
            .define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO)
            .unwrap();
        assert_eq!(app.block_name_from_file("Chair"), "Chair (2)");
        assert_eq!(app.block_name_from_file("Table"), "Table");
    }
}
