//! 左侧栏:项目切换 + 组合树(可展开折叠、拖拽改结构、右键增删改)。
//!
//! 组合下面可以有任意子组合与任意文件夹;点任一节点即把它设为「当前选中」,
//! 主区域随之显示该节点子树里的全部文件夹面板。

use eframe::egui::{
    self, pos2, vec2, Align2, Color32, CornerRadius, Id, Rect, RichText, Sense, Stroke, UiBuilder,
};

use crate::i18n::{self, Key, Lang};
use crate::model::{Combo, Node, Workspace, PALETTE};
use crate::shell::IconCache;
use crate::ui::{style, Action, DropUi};
use crate::app::App;

/// 拖拽落点:放到 `parent` 组合下的第 `index` 个子位置(`usize::MAX` = 追加到末尾)。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct DropTarget {
    pub parent: u64,
    pub index: usize,
    /// 高亮在哪一行上(展示用)。
    pub row: u64,
    /// true = 放进这个组合里面(而不是插到它前面/后面)。
    pub into: bool,
}

/// 改名输入框画在哪一栏。
///
/// 项目根同时出现在左一列(项目列表)和左二列(组合树根)。如果两栏都按
/// `renaming == Some(id)` 画输入框,就会用同一个 widget id 画两个 `TextEdit`:
/// egui 报 ID clash,两个输入框同时处理同一批按键,改名因此不可用。
/// 所以记录本次改名属于哪一栏,只让那一栏画。
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RenameHost {
    /// 左一列:项目列表。
    #[default]
    Projects,
    /// 左二列:组合树(项目根 / 子组合)。
    Tree,
}

impl RenameHost {
    fn tag(self) -> u8 {
        match self {
            Self::Projects => 0,
            Self::Tree => 1,
        }
    }
}

/// 改名输入框的 widget id:同一节点在不同栏里必须是不同的 id。
fn rename_id(host: RenameHost, id: u64) -> Id {
    Id::new(("fd-rename", host.tag(), id))
}

/// 侧栏的临时交互状态。
#[derive(Default)]
pub struct State {
    /// 正在改名的节点 id。
    pub renaming: Option<u64>,
    /// 这次改名由哪一栏发起。
    pub rename_host: RenameHost,
    pub rename_buf: String,
    /// 正在拖拽的节点 id。
    pub drag: Option<u64>,
    /// 这次拖动是否真的越过了 egui 的拖动阈值。
    ///
    /// egui 对 `Sense::drag()` 的控件在**按下**时就会报 `drag_started`,单击也会走
    /// `drag_stopped`;不设这道闸,单击会被当成"位移 0 的拖拽",于是不选中、反而改了结构。
    pub drag_armed: bool,
    /// 当前算出来的落点。
    pub drop: Option<DropTarget>,
    /// 项目列表的拖拽起止下标。
    pub proj_drag: Option<usize>,
    pub proj_drop: Option<usize>,
}

impl State {
    fn clear_drag(&mut self) {
        self.drag = None;
        self.drop = None;
        self.drag_armed = false;
    }

    /// 开始改名(不碰焦点;焦点由 [`start_rename`] 请求)。
    fn begin_rename(&mut self, host: RenameHost, id: u64, name: impl Into<String>) {
        self.renaming = Some(id);
        self.rename_host = host;
        self.rename_buf = name.into();
    }

    /// 这一栏该不该为 `id` 画改名输入框(同一节点只允许一栏画)。
    fn is_renaming(&self, host: RenameHost, id: u64) -> bool {
        self.renaming == Some(id) && self.rename_host == host
    }
}

/// 开始改名并让下一帧的输入框直接拿到键盘焦点。
fn start_rename(ui: &egui::Ui, st: &mut State, host: RenameHost, id: u64, name: &str) {
    st.begin_rename(host, id, name);
    ui.memory_mut(|mem| mem.request_focus(rename_id(host, id)));
}

/// 侧栏第一列:项目列表(点项目 = 看整个项目,可拖动排序)。
pub fn draw_projects(app: &mut App, ui: &mut egui::Ui, actions: &mut Vec<Action>) {
    let App {
        ws,
        sidebar_state: st,
        ..
    } = app;
    let lang = ws.settings.lang;
    let pal = style::palette(style::is_dark(ui.ctx(), ws.settings.theme), ws.settings.accent);
    let m = style::metrics(ws.settings.density);
    ui.spacing_mut().item_spacing.y = 2.0;

    ui.horizontal(|ui| {
        ui.label(
            RichText::new(i18n::t(lang, Key::Projects))
                .size(11.5)
                .color(pal.muted),
        );
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            if style::icon_button(ui, "+", i18n::t(lang, Key::NewProjectTip), &pal).clicked() {
                actions.push(Action::NewProject);
            }
        });
    });
    ui.add_space(2.0);

    let list_height = (ui.available_height() - 30.0).max(60.0);
    egui::ScrollArea::vertical()
        .id_salt("fd-projects")
        .max_height(list_height)
        .auto_shrink([false, false])
        .show(ui, |ui| {
            ui.spacing_mut().item_spacing.y = 1.0;
            let total = ws.projects.len();
            for (index, project) in ws.projects.iter().enumerate() {
                project_row(ws, project, index, total, ui, st, lang, &pal, &m, actions);
            }
        });

    let width = ui.available_width();
    if ui
        .add_sized(
            [width, 22.0],
            egui::Button::new(i18n::t(lang, Key::NewProjectButton)),
        )
        .on_hover_text(i18n::t(lang, Key::NewProjectTip))
        .clicked()
    {
        actions.push(Action::NewProject);
    }
}

/// 侧栏第二列:当前项目的组合树(项目根也是树里的一行)。
pub fn draw_tree(app: &mut App, ui: &mut egui::Ui, actions: &mut Vec<Action>, drop: &mut DropUi) {
    let App {
        ws,
        sidebar_state: st,
        icons,
        ..
    } = app;
    let lang = ws.settings.lang;
    let pal = style::palette(style::is_dark(ui.ctx(), ws.settings.theme), ws.settings.accent);
    let m = style::metrics(ws.settings.density);
    ui.spacing_mut().item_spacing.y = 2.0;

    // 落点每帧重算:由"指针所在的那一行"各自认领(见 `interactions`)。
    // 这里先清上一帧的结果,否则拖着行滑出树外还会留着旧落点、松手就乱改结构。
    st.drop = None;

    let project = ws.active_project().clone();
    ui.horizontal(|ui| {
        let project_color =
            egui::Color32::from_rgb(project.color[0], project.color[1], project.color[2]);
        ui.label(RichText::new(&project.name).size(11.5).color(project_color));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            if style::text_button(
                ui,
                i18n::t(lang, Key::CollapseAll),
                i18n::t(lang, Key::CollapseAll),
                &pal,
                button_width(ui, i18n::t(lang, Key::CollapseAll)),
            )
            .clicked()
            {
                actions.push(Action::SetExpandedAll(false));
            }
            if style::text_button(
                ui,
                i18n::t(lang, Key::ExpandAll),
                i18n::t(lang, Key::ExpandAll),
                &pal,
                button_width(ui, i18n::t(lang, Key::ExpandAll)),
            )
            .clicked()
            {
                actions.push(Action::SetExpandedAll(true));
            }
        });
    });
    ui.add_space(2.0);
    ui.separator();

    let tree_height = (ui.available_height() - 60.0).max(80.0);
    egui::ScrollArea::vertical()
        .id_salt("fd-tree")
        .max_height(tree_height)
        .auto_shrink([false, false])
        .show(ui, |ui| {
            ui.spacing_mut().item_spacing.y = 1.0;
            // 项目根也是一行:点它 = 看整个项目
            draw_combo_row(ws, &project, 0, 0, 0, false, ui, st, lang, &pal, &m, actions, drop);
            if project.expanded {
                draw_children(
                    ws,
                    &project.children,
                    project.id,
                    1,
                    ui,
                    st,
                    icons,
                    lang,
                    &pal,
                    &m,
                    actions,
                    drop,
                );
            }
        });

    ui.add_space(4.0);
    let width = ui.available_width();
    let target_combo = match ws.node(ws.selected) {
        Some(node) => match node.as_combo() {
            Some(combo) => combo.id,
            None => ws.parent_of(node.id()).unwrap_or(ws.active_project),
        },
        None => ws.active_project,
    };
    if ui
        .add_sized(
            [width, 24.0],
            egui::Button::new(i18n::t(lang, Key::AddFolderEllipsis)),
        )
        .on_hover_text(i18n::t(lang, Key::OpenFolderPickTip))
        .clicked()
    {
        actions.push(Action::AddFolderFromPicker { parent: target_combo });
    }
    if ui
        .add_sized(
            [width, 24.0],
            egui::Button::new(i18n::t(lang, Key::AddComboEllipsis)),
        )
        .on_hover_text(i18n::t(lang, Key::NewComboTip))
        .clicked()
    {
        actions.push(Action::AddCombo {
            parent: target_combo,
        });
    }
}

/// 展开箭头:自己画三角形,别依赖字体里有没有 ▸/▾(换个展示字体就可能变豆腐块)。
fn draw_disclosure(ui: &egui::Ui, rect: Rect, expanded: bool, color: Color32) {
    let c = rect.center();
    let s = 3.6;
    let points = if expanded {
        vec![
            pos2(c.x - s, c.y - s * 0.7),
            pos2(c.x + s, c.y - s * 0.7),
            pos2(c.x, c.y + s * 0.9),
        ]
    } else {
        vec![
            pos2(c.x - s * 0.7, c.y - s),
            pos2(c.x - s * 0.7, c.y + s),
            pos2(c.x + s * 0.9, c.y),
        ]
    };
    ui.painter()
        .add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
}

/// 按钮宽度按文字量出来(中英文长度差很大,写死会挤在一起)。
fn button_width(ui: &egui::Ui, text: &str) -> f32 {
    ui.painter()
        .layout_no_wrap(text.to_string(), style::small_font(), egui::Color32::WHITE)
        .size()
        .x
        + 14.0
}

/// 项目列表里的一行。
#[allow(clippy::too_many_arguments)]
fn project_row(
    ws: &Workspace,
    project: &Combo,
    index: usize,
    total: usize,
    ui: &mut egui::Ui,
    st: &mut State,
    lang: Lang,
    pal: &style::Palette,
    m: &style::Metrics,
    actions: &mut Vec<Action>,
) {
    let (rect, resp) =
        ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), Sense::click_and_drag());
    let is_active = ws.active_project == project.id;
    let selected = ws.selected == project.id;
    if selected {
        ui.painter()
            .rect_filled(rect, CornerRadius::same(5), pal.accent_soft);
    } else if resp.hovered() || st.proj_drag == Some(index) {
        ui.painter()
            .rect_filled(rect, CornerRadius::same(5), pal.hover);
    }
    // 拖动排序落点
    if let (Some(from), Some(to)) = (st.proj_drag, st.proj_drop) {
        if from != to && to == index {
            let y = if to > from { rect.bottom() } else { rect.top() };
            ui.painter().line_segment(
                [pos2(rect.left() + 3.0, y), pos2(rect.right() - 3.0, y)],
                Stroke::new(2.0, pal.accent),
            );
        }
    }

    ui.painter().circle_filled(
        pos2(rect.left() + 10.0, rect.center().y),
        4.5,
        Color32::from_rgb(project.color[0], project.color[1], project.color[2]),
    );
    if st.is_renaming(RenameHost::Projects, project.id) {
        let text_rect = Rect::from_min_max(
            pos2(rect.left() + 19.0, rect.top() + 1.0),
            pos2(rect.right() - 24.0, rect.bottom() - 1.0),
        );
        edit_name(ui, st, RenameHost::Projects, project.id, text_rect, lang, actions);
    } else {
        let clip = Rect::from_min_max(
            pos2(rect.left() + 19.0, rect.top()),
            pos2(rect.right() - 24.0, rect.bottom()),
        );
        ui.painter().with_clip_rect(clip).text(
            pos2(rect.left() + 19.0, rect.center().y),
            Align2::LEFT_CENTER,
            &project.name,
            style::body_font(),
            if is_active { pal.text } else { pal.text.gamma_multiply(0.85) },
        );
    }
    ui.painter().text(
        pos2(rect.right() - 6.0, rect.center().y),
        Align2::RIGHT_CENTER,
        ws.folder_count(project.id).to_string(),
        style::small_font(),
        pal.muted,
    );

    if resp.clicked() {
        actions.push(Action::SwitchProject(project.id));
    }
    if resp.drag_started() {
        st.proj_drag = Some(index);
        st.proj_drop = None;
    }
    // 同组合树:只有真的拖动过才算落点,否则单击会变成"重排"而不是"切换项目"
    if st.proj_drag == Some(index) && resp.dragged() {
        if let Some(pointer) = resp.hover_pos() {
            let pitch = m.tree_row + 1.0;
            let delta = ((pointer.y - resp.rect.center().y) / pitch).round() as isize;
            let target = (index as isize + delta).clamp(0, total as isize - 1) as usize;
            st.proj_drop = Some(target);
        }
    }
    if resp.drag_stopped() {
        if let (Some(from), Some(to)) = (st.proj_drag.take(), st.proj_drop.take()) {
            if from != to {
                actions.push(Action::MoveProject { from, to });
            }
        }
    }

    let id = project.id;
    let name = project.name.clone();
    let mut remove = false;
    resp.context_menu(|ui| {
        if ui.button(i18n::t(lang, Key::Rename)).clicked() {
            start_rename(ui, st, RenameHost::Projects, id, &name);
            ui.close();
        }
        if ui.button(i18n::t(lang, Key::DuplicateCombo)).clicked() {
            actions.push(Action::DuplicateNode(id));
            ui.close();
        }
        ui.separator();
        if ui
            .add_enabled(
                ws.projects.len() > 1,
                egui::Button::new(i18n::t(lang, Key::Delete)),
            )
            .clicked()
        {
            remove = true;
            ui.close();
        }
    });
    if remove {
        actions.push(Action::RemoveNode(id));
    }
}

/// 递归画组合的直接子节点。
#[allow(clippy::too_many_arguments)]
fn draw_children(
    ws: &Workspace,
    children: &[Node],
    parent: u64,
    depth: usize,
    ui: &mut egui::Ui,
    st: &mut State,
    icons: &IconCache,
    lang: Lang,
    pal: &style::Palette,
    m: &style::Metrics,
    actions: &mut Vec<Action>,
    drop: &mut DropUi,
) {
    for (index, child) in children.iter().enumerate() {
        match child {
            Node::Combo(combo) => {
                draw_combo_row(
                    ws, combo, parent, index, depth, true, ui, st, lang, pal, m, actions, drop,
                );
                if combo.expanded {
                    draw_children(
                        ws,
                        &combo.children,
                        combo.id,
                        depth + 1,
                        ui,
                        st,
                        icons,
                        lang,
                        pal,
                        m,
                        actions,
                        drop,
                    );
                }
            }
            Node::Folder(folder) => draw_folder_row(
                ws,
                &folder.title(),
                &folder.path,
                folder.id,
                parent,
                index,
                depth,
                ui,
                st,
                icons,
                lang,
                pal,
                m,
                actions,
                drop,
            ),
        }
    }
}

/// 组合行。
#[allow(clippy::too_many_arguments)]
fn draw_combo_row(
    ws: &Workspace,
    combo: &Combo,
    parent: u64,
    index: usize,
    depth: usize,
    draggable: bool,
    ui: &mut egui::Ui,
    st: &mut State,
    lang: Lang,
    pal: &style::Palette,
    m: &style::Metrics,
    actions: &mut Vec<Action>,
    drop: &mut DropUi,
) {
    // 项目根不允许拖动(它没有父组合)
    let sense = if draggable {
        Sense::click_and_drag()
    } else {
        Sense::click()
    };
    let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), sense);
    let indent = 4.0 + depth as f32 * 14.0;
    let selected = ws.selected == combo.id;
    paint_row_bg(ui, rect, resp.hovered() || st.drag == Some(combo.id), selected, pal);
    paint_drop_hint(ui, rect, combo.id, index, st, pal);

    // 展开箭头(单独一个命中区,别和整行的选中点击打架)
    let arrow_rect = Rect::from_min_size(
        pos2(rect.left() + indent, rect.top()),
        vec2(14.0, rect.height()),
    );
    draw_disclosure(ui, arrow_rect, combo.expanded, pal.muted);
    let arrow = ui.interact(arrow_rect, Id::new(("fd-arrow", combo.id)), Sense::click());
    if arrow.clicked() {
        actions.push(Action::ToggleExpanded(combo.id));
    }

    ui.painter().circle_filled(
        pos2(rect.left() + indent + 22.0, rect.center().y),
        4.0,
        Color32::from_rgb(combo.color[0], combo.color[1], combo.color[2]),
    );

    let text_left = rect.left() + indent + 31.0;
    let text_clip =
        Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 30.0, rect.bottom()));
    if st.is_renaming(RenameHost::Tree, combo.id) {
        edit_name(
            ui,
            st,
            RenameHost::Tree,
            combo.id,
            text_clip.shrink2(vec2(0.0, 1.0)),
            lang,
            actions,
        );
    } else {
        ui.painter().with_clip_rect(text_clip).text(
            pos2(text_left, rect.center().y),
            Align2::LEFT_CENTER,
            &combo.name,
            style::body_font(),
            pal.text,
        );
    }
    ui.painter().text(
        pos2(rect.right() - 6.0, rect.center().y),
        Align2::RIGHT_CENTER,
        ws.folder_count(combo.id).to_string(),
        style::small_font(),
        pal.muted,
    );

    interactions(&resp, st, combo.id, parent, index, true, draggable, actions);
    context_menu(
        &resp,
        st,
        RenameHost::Tree,
        combo.id,
        true,
        &combo.name,
        lang,
        pal,
        actions,
    );
    // 落到组合行:把拖进来的文件夹加成它的子节点
    if let Some(pending) = drop.claim(rect) {
        actions.push(Action::DropIntoNode {
            node: combo.id,
            paths: pending.paths,
        });
    } else {
        drop.highlight(ui, rect, pal);
    }
}

/// 文件夹行(叶子)。
#[allow(clippy::too_many_arguments)]
fn draw_folder_row(
    ws: &Workspace,
    title: &str,
    path: &std::path::Path,
    id: u64,
    parent: u64,
    index: usize,
    depth: usize,
    ui: &mut egui::Ui,
    st: &mut State,
    icons: &IconCache,
    lang: Lang,
    pal: &style::Palette,
    m: &style::Metrics,
    actions: &mut Vec<Action>,
    drop: &mut DropUi,
) {
    let (rect, resp) =
        ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), Sense::click_and_drag());
    let indent = 4.0 + depth as f32 * 14.0;
    let selected = ws.selected == id;
    paint_row_bg(ui, rect, resp.hovered() || st.drag == Some(id), selected, pal);
    paint_drop_hint(ui, rect, id, index, st, pal);

    let icon_rect = Rect::from_center_size(
        pos2(rect.left() + indent + 19.0, rect.center().y),
        vec2(16.0, 16.0),
    );
    crate::ui::vector_icons::draw_icon(ui.ctx(), ui.painter(), icons, icon_rect, path, true, pal);

    let text_left = rect.left() + indent + 31.0;
    let text_clip =
        Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 6.0, rect.bottom()));
    ui.painter().with_clip_rect(text_clip).text(
        pos2(text_left, rect.center().y),
        Align2::LEFT_CENTER,
        title,
        style::body_font(),
        pal.text,
    );

    interactions(&resp, st, id, parent, index, false, true, actions);
    context_menu(
        &resp,
        st,
        RenameHost::Tree,
        id,
        false,
        title,
        lang,
        pal,
        actions,
    );
    // 落到文件夹行:加到它所在的组合里(结构操作,不动文件)
    if let Some(pending) = drop.claim(rect) {
        actions.push(Action::DropIntoNode {
            node: id,
            paths: pending.paths,
        });
    } else {
        drop.highlight(ui, rect, pal);
    }
    let _ = resp.on_hover_text(path.display().to_string());
}

/// 行底色。
fn paint_row_bg(ui: &egui::Ui, rect: Rect, hovered: bool, selected: bool, pal: &style::Palette) {
    if selected {
        ui.painter()
            .rect_filled(rect, CornerRadius::same(5), pal.accent_soft);
    } else if hovered {
        ui.painter()
            .rect_filled(rect, CornerRadius::same(5), pal.hover);
    }
}

/// 拖拽落点提示:`into` 画整行描边,插入画一行横线(按 index 判断画上沿还是下沿)。
fn paint_drop_hint(
    ui: &egui::Ui,
    rect: Rect,
    row_id: u64,
    my_index: usize,
    st: &State,
    pal: &style::Palette,
) {
    if st.drag.is_none() {
        return;
    }
    let Some(target) = st.drop else { return };
    if target.row != row_id {
        return;
    }
    if target.into {
        ui.painter().rect_stroke(
            rect,
            CornerRadius::same(5),
            Stroke::new(2.0, pal.accent),
            egui::StrokeKind::Inside,
        );
    } else {
        let y = if target.index == my_index {
            rect.top()
        } else {
            rect.bottom()
        };
        ui.painter().line_segment(
            [pos2(rect.left() + 4.0, y), pos2(rect.right() - 4.0, y)],
            Stroke::new(2.0, pal.accent),
        );
    }
}

/// 组合树里"这一行要不要认领这次落点"的规则。
///
/// 每行每帧都算一遍,指针落在谁身上谁就报出自己的 `parent`/`index`。
/// 抽成纯函数是为了能测:第一个判断(**被拖行绝不认领**)正是上一版失效的根因——
/// 那时落点只在被拖行自己的矩形上算、`parent`/`index` 也取自它自己,
/// 于是拖到别的行上目标仍然是它自己,看着就像"拖了没反应"。
#[allow(clippy::too_many_arguments)]
fn claim_drop(
    dragging: Option<u64>,
    armed: bool,
    id: u64,
    parent: u64,
    index: usize,
    is_combo: bool,
    rect: Rect,
    pointer: egui::Pos2,
) -> Option<DropTarget> {
    // 没在拖 / 还没越过拖动阈值(单击)/ 指针不在这一行 / 这一行就是被拖的那一行 → 不认领。
    let dragged = dragging?;
    if !armed || dragged == id || !rect.contains(pointer) {
        return None;
    }
    let rel = ((pointer.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0);
    Some(if is_combo && (0.3..0.7).contains(&rel) {
        // 组合行的中带:放进这个组合里(追加到末尾)。
        DropTarget {
            parent: id,
            index: usize::MAX,
            row: id,
            into: true,
        }
    } else if rel < 0.5 {
        DropTarget {
            parent,
            index,
            row: id,
            into: false,
        }
    } else {
        DropTarget {
            parent,
            index: index + 1,
            row: id,
            into: false,
        }
    })
}

/// 点击 / 拖拽落点。
#[allow(clippy::too_many_arguments)]
fn interactions(
    resp: &egui::Response,
    st: &mut State,
    id: u64,
    parent: u64,
    index: usize,
    is_combo: bool,
    draggable: bool,
    actions: &mut Vec<Action>,
) {
    if resp.clicked() {
        actions.push(Action::SelectNode(id));
    }
    if resp.double_clicked() && is_combo {
        actions.push(Action::ToggleExpanded(id));
    }
    if !draggable {
        return;
    }
    if resp.drag_started() {
        st.drag = Some(id);
        st.drop = None;
        st.drag_armed = false;
    }
    // 被拖行自己判定"这次拖动是真的":按下之后越过阈值才算,单击不算。
    if st.drag == Some(id) && resp.dragged() {
        st.drag_armed = true;
    }
    // 落点由**指针所在的那一行自己**认领(规则见 `claim_drop`)。
    // 用 `pointer.latest_pos()` 而不是 `resp.hover_pos()`:拖动中的控件不算 hover,
    // 后者拿到的是旧位置。指针不在树上时没人认领,`st.drop` 保持为 `None`(见 `draw_tree`)。
    if let Some(pointer) = resp.ctx.input(|i| i.pointer.latest_pos()) {
        if let Some(target) = claim_drop(
            st.drag,
            st.drag_armed,
            id,
            parent,
            index,
            is_combo,
            resp.rect,
            pointer,
        ) {
            st.drop = Some(target);
        }
    }
    if resp.drag_stopped() {
        if let (Some(dragged), Some(target)) = (st.drag, st.drop) {
            actions.push(Action::MoveNode {
                id: dragged,
                parent: target.parent,
                index: target.index,
            });
        }
        st.clear_drag();
    }
}

/// 节点右键菜单。
#[allow(clippy::too_many_arguments)]
fn context_menu(
    resp: &egui::Response,
    st: &mut State,
    host: RenameHost,
    id: u64,
    is_combo: bool,
    current_name: &str,
    lang: Lang,
    pal: &style::Palette,
    actions: &mut Vec<Action>,
) {
    let name = current_name.to_string();
    resp.context_menu(|ui| {
        // 文件夹只是路径:改名不会改文件系统,干脆不提供。
        if is_combo && ui.button(i18n::t(lang, Key::Rename)).clicked() {
            start_rename(ui, st, host, id, &name);
            ui.close();
        }
        if ui.button(i18n::t(lang, Key::DuplicateCombo)).clicked() {
            actions.push(Action::DuplicateNode(id));
            ui.close();
        }
        ui.separator();
        if is_combo {
            if ui.button(i18n::t(lang, Key::AddSubCombo)).clicked() {
                actions.push(Action::AddCombo { parent: id });
                ui.close();
            }
            if ui.button(i18n::t(lang, Key::AddFolder)).clicked() {
                actions.push(Action::AddFolderFromPicker { parent: id });
                ui.close();
            }
            ui.separator();
            ui.label(
                RichText::new(i18n::t(lang, Key::Color))
                    .size(11.5)
                    .color(pal.muted),
            );
            ui.horizontal(|ui| {
                for color in PALETTE {
                    let (dot, hit) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
                    ui.painter().circle_filled(
                        dot.center(),
                        5.0,
                        Color32::from_rgb(color[0], color[1], color[2]),
                    );
                    if hit.clicked() {
                        actions.push(Action::SetNodeColor { id, color });
                        ui.close();
                    }
                }
            });
        } else {
            if ui.button(i18n::t(lang, Key::RevealInExplorer)).clicked() {
                actions.push(Action::RevealNode(id));
                ui.close();
            }
            if ui.button(i18n::t(lang, Key::CopyPath)).clicked() {
                actions.push(Action::CopyNodePath(id));
                ui.close();
            }
        }
        ui.separator();
        if ui.button(i18n::t(lang, Key::DeleteNode)).clicked() {
            actions.push(Action::RemoveNode(id));
            ui.close();
        }
    });
}

/// 改名输入框。
fn edit_name(
    ui: &mut egui::Ui,
    st: &mut State,
    host: RenameHost,
    id: u64,
    rect: Rect,
    _lang: Lang,
    actions: &mut Vec<Action>,
) {
    let mut commit = false;
    let mut cancel = false;
    ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
        let text = ui.add(
            egui::TextEdit::singleline(&mut st.rename_buf)
                .id(rename_id(host, id))
                .desired_width(ui.available_width())
                .font(style::body_font()),
        );
        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
            cancel = true;
        } else if text.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
            commit = true;
        }
    });
    if cancel {
        st.renaming = None;
    } else if commit {
        actions.push(Action::RenameNode {
            id,
            name: st.rename_buf.clone(),
        });
        st.renaming = None;
    }
}

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

    /// 一行:x 100..300、y 100..120。
    fn row() -> Rect {
        Rect::from_min_size(pos2(100.0, 100.0), vec2(200.0, 20.0))
    }

    #[test]
    fn dragged_row_never_claims_its_own_drop() {
        // 回归:上一版落点只在被拖行自己的矩形上算,拖到别的行上目标仍是自己。
        assert_eq!(
            claim_drop(Some(7), true, 7, 1, 0, false, row(), pos2(150.0, 110.0)),
            None
        );
    }

    #[test]
    fn row_claim_follows_pointer_half() {
        let r = row();
        // 上半 → 插到自己前面;下半 → 插到自己后面。
        assert_eq!(
            claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 104.0)),
            Some(DropTarget {
                parent: 3,
                index: 2,
                row: 9,
                into: false
            })
        );
        assert_eq!(
            claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 116.0)),
            Some(DropTarget {
                parent: 3,
                index: 3,
                row: 9,
                into: false
            })
        );
    }

    #[test]
    fn combo_middle_band_means_drop_into_it() {
        let r = row();
        assert_eq!(
            claim_drop(Some(7), true, 5, 1, 0, true, r, pos2(150.0, 110.0)),
            Some(DropTarget {
                parent: 5,
                index: usize::MAX,
                row: 5,
                into: true
            })
        );
        // 文件夹行没有"放进"这一档:中带按上下半插到前后。
        let folder = claim_drop(Some(7), true, 5, 1, 0, false, r, pos2(150.0, 110.0));
        assert_eq!(folder.map(|t| t.into), Some(false));
        assert_eq!(folder.map(|t| t.index), Some(1), "中带偏下 → 插到它后面");
    }

    #[test]
    fn rename_editor_is_only_drawn_by_the_originating_column() {
        // 回归:项目根同时出现在项目列表和组合树里。两栏都画输入框会撞 widget id,
        // 按键被两个 TextEdit 同时处理,改名就不对了。
        let mut st = State::default();
        st.begin_rename(RenameHost::Projects, 7, "Alpha");
        assert!(st.is_renaming(RenameHost::Projects, 7));
        assert!(!st.is_renaming(RenameHost::Tree, 7), "树根不能重复画同一个编辑器");
        assert!(!st.is_renaming(RenameHost::Projects, 8));

        // 从树里对同一个项目根开始改名:归属切换,项目列表不再画。
        st.begin_rename(RenameHost::Tree, 7, "Alpha");
        assert!(st.is_renaming(RenameHost::Tree, 7));
        assert!(!st.is_renaming(RenameHost::Projects, 7));

        st.renaming = None;
        assert!(!st.is_renaming(RenameHost::Tree, 7), "提交/取消后编辑器消失");
    }

    #[test]
    fn project_root_rename_editor_receives_each_key_once() {
        // 回归:项目根同时出现在项目列表和组合树根。两处都画同 id 的 TextEdit 时,
        // 一次按键会被两个输入框各处理一遍(文本变成两次),改名因此坏掉。
        // 这里把两栏画进同一帧,验证按键只落一次。
        let mut ws = Workspace::default();
        ws.projects.push(crate::model::Combo::new(1, "Alpha", PALETTE[0]));
        ws.active_project = 1;
        ws.selected = 1;

        let mut st = State::default();
        st.begin_rename(RenameHost::Projects, 1, "Alpha");
        let mut actions: Vec<Action> = Vec::new();
        let mut drop = DropUi::default();
        let pal = style::palette(true, PALETTE[0]);
        let m = style::metrics(crate::model::Density::Cozy);
        let ctx = egui::Context::default();
        ctx.memory_mut(|mem| mem.request_focus(rename_id(RenameHost::Projects, 1)));

        let raw = || egui::RawInput {
            screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
            ..Default::default()
        };
        let mut draw = |ui: &mut egui::Ui, st: &mut State, actions: &mut Vec<Action>| {
            project_row(
                &ws,
                ws.project(1).unwrap(),
                0,
                1,
                ui,
                st,
                Lang::Zh,
                &pal,
                &m,
                actions,
            );
            let root = ws.active_project().clone();
            draw_combo_row(
                &ws, &root, 0, 0, 0, false, ui, st, Lang::Zh, &pal, &m, actions, &mut drop,
            );
        };

        let mut out = ctx.run_ui(raw(), |ui| draw(ui, &mut st, &mut actions));
        out.textures_delta.clear();
        let mut input = raw();
        input.events.push(egui::Event::Text("X".into()));
        let mut out = ctx.run_ui(input, |ui| draw(ui, &mut st, &mut actions));
        out.textures_delta.clear();

        assert_eq!(st.rename_buf, "AlphaX", "一次按键只能被一个输入框处理");
        assert!(actions.is_empty(), "还没按 Enter,不应提交");
    }

    #[test]
    fn rename_editor_ids_do_not_collide_across_columns() {
        assert_ne!(
            rename_id(RenameHost::Projects, 7),
            rename_id(RenameHost::Tree, 7)
        );
        assert_eq!(
            rename_id(RenameHost::Tree, 7),
            rename_id(RenameHost::Tree, 7),
            "同一栏同一节点的 id 要稳定(焦点请求才能对上)"
        );
        assert_ne!(
            rename_id(RenameHost::Tree, 7),
            rename_id(RenameHost::Tree, 8)
        );
    }

    #[test]
    fn nothing_claims_without_a_real_drag_or_outside_the_row() {
        let r = row();
        assert_eq!(
            claim_drop(Some(7), false, 9, 3, 2, false, r, pos2(150.0, 104.0)),
            None,
            "没过拖动阈值(单击)不认领"
        );
        assert_eq!(
            claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 300.0)),
            None,
            "指针不在这一行上"
        );
        assert_eq!(
            claim_drop(None, true, 9, 3, 2, false, r, pos2(150.0, 104.0)),
            None,
            "根本没在拖"
        );
    }
}