use super::drag_drop_state::{resolve_drag_effect, TreeShellDragEffect};
use super::filetree_selection::{FileTreeSelectionModifiers, FileTreeSelectionState};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeEnvironment {
    pub visible_row_ids: Vec<String>,
    pub rows: Vec<FileTreeRuntimeRow>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root_uri: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeRow {
    pub row_id: String,
    pub row_kind: String,
    pub document_id: Option<String>,
    pub asset_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
    pub active_row_id: Option<String>,
    pub selection: FileTreeSelectionState,
    pub drag_row_ids: Vec<String>,
    pub drag_effect: Option<TreeShellDragEffect>,
    pub drop_target_row_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRuntimeTransition {
    pub state: FileTreeRuntimeState,
    pub outputs: BTreeSet<FileTreeRuntimeOutput>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
    tag = "kind",
    rename_all = "camelCase",
    rename_all_fields = "camelCase"
)]
pub enum FileTreeRuntimeAction {
    SelectRow {
        row_id: String,
        modifiers: FileTreeSelectionModifiers,
    },
    SelectContextRow {
        row_id: String,
    },
    NormalizeVisibleRows,
    ClearSelection,
    ResolveDragRows {
        row_id: String,
        has_external_files: bool,
        alt_key: bool,
    },
    UpdateDropTarget {
        row_id: Option<String>,
    },
    DispatchInternalDrop {
        target_row_id: Option<String>,
        row_ids: Vec<String>,
        copy: bool,
    },
    DispatchExternalDrop {
        target_row_id: Option<String>,
        file_count: u32,
    },
    OpenRow {
        row_id: String,
    },
    FocusNext,
    FocusPrevious,
    FocusFirst,
    FocusLast,
    OpenFocused,
    ContextMenuFocused,
    BeginRenameFocused,
    DeleteSelection,
    CopySelection,
    CutSelection,
    PasteIntoFocused,
    Escape,
    ContextMenuRow {
        row_id: String,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeRuntimeOutput {
    DomPatch,
    Intent(FileTreeIntentEvent),
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeIntentEvent {
    Open {
        target: FileTreeOpenTarget,
    },
    ContextMenu {
        row_id: String,
        target: FileTreeOpenTarget,
    },
    KeyboardCommand {
        command: FileTreeKeyboardCommand,
        row_ids: Vec<String>,
        target_row_id: Option<String>,
    },
    InternalDrop {
        target_row_id: Option<String>,
        target: Option<FileTreeOpenTarget>,
        row_ids: Vec<String>,
        copy: bool,
    },
    ExternalDrop {
        target_row_id: Option<String>,
        target: Option<FileTreeOpenTarget>,
        file_count: u32,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileTreeKeyboardCommand {
    Rename,
    Delete,
    Copy,
    Cut,
    Paste,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
    tag = "kind",
    rename_all = "camelCase",
    rename_all_fields = "camelCase"
)]
pub enum FileTreeOpenTarget {
    Document {
        document_id: String,
    },
    Index {
        document_id: String,
    },
    AssetFolder {
        document_id: String,
        asset_id: String,
    },
    Asset {
        document_id: String,
        asset_id: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        resource_kind: Option<String>,
    },
    LocalFile {
        root_uri: String,
        path: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        resource_kind: Option<String>,
    },
    Directory {
        root_uri: String,
        path: String,
    },
}

impl FileTreeRuntimeState {
    pub fn reduce(
        &self,
        env: &FileTreeRuntimeEnvironment,
        action: FileTreeRuntimeAction,
    ) -> FileTreeRuntimeTransition {
        match action {
            FileTreeRuntimeAction::SelectRow { row_id, modifiers } => {
                let mut state = self.clone();
                state.selection =
                    state
                        .selection
                        .select_row(&row_id, &env.visible_row_ids, modifiers);
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::SelectContextRow { row_id } => {
                let mut state = self.clone();
                state.selection = state.selection.select_context_row(&row_id);
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::NormalizeVisibleRows => {
                let mut state = self.clone();
                state.selection = state
                    .selection
                    .normalize_for_visible_rows(&env.visible_row_ids);
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::ClearSelection => {
                let mut state = self.clone();
                state.selection = state.selection.clear();
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::ResolveDragRows {
                row_id,
                has_external_files,
                alt_key,
            } => {
                let mut state = self.clone();
                state.drag_row_ids = state
                    .selection
                    .resolve_drag_row_ids_for_visible_rows(&row_id, &env.visible_row_ids);
                state.drag_effect = Some(resolve_drag_effect(has_external_files, alt_key));
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::UpdateDropTarget { row_id } => {
                let mut state = self.clone();
                state.drop_target_row_id = row_id;
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::DispatchInternalDrop {
                target_row_id,
                row_ids,
                copy,
            } => {
                let mut state = self.clone();
                state.drag_row_ids = Vec::new();
                state.drag_effect = None;
                state.drop_target_row_id = None;
                let row_ids = row_ids
                    .into_iter()
                    .filter(|row_id| !row_id.is_empty())
                    .collect::<Vec<_>>();
                if row_ids.is_empty() {
                    return transition(state, [FileTreeRuntimeOutput::DomPatch]);
                }
                let target = target_row_id
                    .as_deref()
                    .and_then(|row_id| resolve_open_target(env, row_id));
                let Some(target) = target else {
                    return transition(state, [FileTreeRuntimeOutput::DomPatch]);
                };
                transition(
                    state,
                    [
                        FileTreeRuntimeOutput::DomPatch,
                        FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
                            target_row_id,
                            target: Some(target),
                            row_ids,
                            copy,
                        }),
                    ],
                )
            }
            FileTreeRuntimeAction::DispatchExternalDrop {
                target_row_id,
                file_count,
            } => {
                let mut state = self.clone();
                state.drag_row_ids = Vec::new();
                state.drag_effect = None;
                state.drop_target_row_id = None;
                if file_count == 0 {
                    return transition(state, [FileTreeRuntimeOutput::DomPatch]);
                }
                let target = target_row_id
                    .as_deref()
                    .and_then(|row_id| resolve_open_target(env, row_id));
                let Some(target) = target else {
                    return transition(state, [FileTreeRuntimeOutput::DomPatch]);
                };
                transition(
                    state,
                    [
                        FileTreeRuntimeOutput::DomPatch,
                        FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
                            target_row_id,
                            target: Some(target),
                            file_count,
                        }),
                    ],
                )
            }
            FileTreeRuntimeAction::OpenRow { row_id } => {
                if let Some(target) = resolve_open_target(env, &row_id) {
                    let mut state = self.clone();
                    state.active_row_id = Some(row_id);
                    transition(
                        state,
                        [
                            FileTreeRuntimeOutput::DomPatch,
                            FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }),
                        ],
                    )
                } else {
                    transition(self.clone(), [])
                }
            }
            FileTreeRuntimeAction::FocusNext => transition(
                focus_relative_row(self, env, 1),
                [FileTreeRuntimeOutput::DomPatch],
            ),
            FileTreeRuntimeAction::FocusPrevious => transition(
                focus_relative_row(self, env, -1),
                [FileTreeRuntimeOutput::DomPatch],
            ),
            FileTreeRuntimeAction::FocusFirst => transition(
                focus_absolute_row(self, env, 0),
                [FileTreeRuntimeOutput::DomPatch],
            ),
            FileTreeRuntimeAction::FocusLast => transition(
                focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)),
                [FileTreeRuntimeOutput::DomPatch],
            ),
            FileTreeRuntimeAction::OpenFocused => {
                if let Some(row_id) = self.selection.focused_row_id.clone() {
                    self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id })
                } else {
                    transition(self.clone(), [])
                }
            }
            FileTreeRuntimeAction::ContextMenuFocused => {
                if let Some(row_id) = self.selection.focused_row_id.clone() {
                    self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id })
                } else {
                    transition(self.clone(), [])
                }
            }
            FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition(
                self,
                FileTreeKeyboardCommand::Rename,
                selected_or_focused_rows(self),
                self.selection.focused_row_id.clone(),
            ),
            FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition(
                self,
                FileTreeKeyboardCommand::Delete,
                selected_or_focused_rows(self),
                self.selection.focused_row_id.clone(),
            ),
            FileTreeRuntimeAction::CopySelection => keyboard_command_transition(
                self,
                FileTreeKeyboardCommand::Copy,
                selected_or_focused_rows(self),
                self.selection.focused_row_id.clone(),
            ),
            FileTreeRuntimeAction::CutSelection => keyboard_command_transition(
                self,
                FileTreeKeyboardCommand::Cut,
                selected_or_focused_rows(self),
                self.selection.focused_row_id.clone(),
            ),
            FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition(
                self,
                FileTreeKeyboardCommand::Paste,
                Vec::new(),
                self.selection.focused_row_id.clone(),
            ),
            FileTreeRuntimeAction::Escape => {
                let mut state = self.clone();
                state.drag_row_ids = Vec::new();
                state.drag_effect = None;
                state.drop_target_row_id = None;
                transition(state, [FileTreeRuntimeOutput::DomPatch])
            }
            FileTreeRuntimeAction::ContextMenuRow { row_id } => {
                if let Some(target) = resolve_open_target(env, &row_id) {
                    transition(
                        self.clone(),
                        [FileTreeRuntimeOutput::Intent(
                            FileTreeIntentEvent::ContextMenu { row_id, target },
                        )],
                    )
                } else {
                    transition(self.clone(), [])
                }
            }
        }
    }
}

fn focus_relative_row(
    state: &FileTreeRuntimeState,
    env: &FileTreeRuntimeEnvironment,
    offset: isize,
) -> FileTreeRuntimeState {
    if env.visible_row_ids.is_empty() {
        return state.clone();
    }
    let current = state
        .selection
        .focused_row_id
        .as_ref()
        .and_then(|row_id| {
            env.visible_row_ids
                .iter()
                .position(|candidate| candidate == row_id)
        })
        .unwrap_or(0);
    let next = if offset.is_negative() {
        current.saturating_sub(offset.unsigned_abs())
    } else {
        (current + offset as usize).min(env.visible_row_ids.len() - 1)
    };
    focus_absolute_row(state, env, next)
}

fn focus_absolute_row(
    state: &FileTreeRuntimeState,
    env: &FileTreeRuntimeEnvironment,
    index: usize,
) -> FileTreeRuntimeState {
    let mut next = state.clone();
    next.selection.focused_row_id = env.visible_row_ids.get(index).cloned();
    next
}

fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec<String> {
    if !state.selection.selected_row_ids.is_empty() {
        return state.selection.selected_row_ids.iter().cloned().collect();
    }
    state
        .selection
        .focused_row_id
        .iter()
        .cloned()
        .collect::<Vec<_>>()
}

fn keyboard_command_transition(
    state: &FileTreeRuntimeState,
    command: FileTreeKeyboardCommand,
    row_ids: Vec<String>,
    target_row_id: Option<String>,
) -> FileTreeRuntimeTransition {
    if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) {
        return transition(state.clone(), []);
    }
    transition(
        state.clone(),
        [FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::KeyboardCommand {
                command,
                row_ids,
                target_row_id,
            },
        )],
    )
}

fn resolve_open_target(
    env: &FileTreeRuntimeEnvironment,
    row_id: &str,
) -> Option<FileTreeOpenTarget> {
    let row_by_id = env
        .rows
        .iter()
        .map(|row| (row.row_id.as_str(), row))
        .collect::<BTreeMap<_, _>>();
    let row = row_by_id.get(row_id)?;
    match row.row_kind.as_str() {
        "doc" | "document" => Some(FileTreeOpenTarget::Document {
            document_id: row.document_id.clone()?,
        }),
        "index" => Some(FileTreeOpenTarget::Index {
            document_id: row.document_id.clone()?,
        }),
        "asset-folder" | "asset_folder" => Some(FileTreeOpenTarget::AssetFolder {
            document_id: row.document_id.clone()?,
            asset_id: row.asset_id.clone()?,
        }),
        "local-file" | "local_file" => Some(FileTreeOpenTarget::LocalFile {
            root_uri: env.root_uri.clone().unwrap_or_default(),
            path: row.asset_id.clone().unwrap_or_default(),
            resource_kind: None,
        }),
        "directory" => Some(FileTreeOpenTarget::Directory {
            root_uri: env.root_uri.clone().unwrap_or_default(),
            path: row.asset_id.clone().unwrap_or_default(),
        }),
        "asset" => Some(FileTreeOpenTarget::Asset {
            document_id: row.document_id.clone()?,
            asset_id: row.asset_id.clone()?,
            resource_kind: None,
        }),
        "office" | "image" | "pdf" | "code" | "text" | "mindmap" | "table" => {
            Some(FileTreeOpenTarget::Asset {
                document_id: row.document_id.clone()?,
                asset_id: row.asset_id.clone()?,
                resource_kind: Some(row.row_kind.clone()),
            })
        }
        _ => None,
    }
}

fn transition<const N: usize>(
    state: FileTreeRuntimeState,
    outputs: [FileTreeRuntimeOutput; N],
) -> FileTreeRuntimeTransition {
    FileTreeRuntimeTransition {
        state,
        outputs: outputs.into_iter().collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeOpenTarget, FileTreeRuntimeAction,
        FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeRow,
        FileTreeRuntimeState,
    };
    use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
    use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;

    fn ids(values: &[&str]) -> Vec<String> {
        values.iter().map(|value| (*value).to_string()).collect()
    }

    fn env() -> FileTreeRuntimeEnvironment {
        FileTreeRuntimeEnvironment {
            visible_row_ids: ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"]),
            rows: vec![
                FileTreeRuntimeRow {
                    row_id: "doc:root".into(),
                    row_kind: "doc".into(),
                    document_id: Some("root".into()),
                    asset_id: None,
                },
                FileTreeRuntimeRow {
                    row_id: "index:root".into(),
                    row_kind: "index".into(),
                    document_id: Some("root".into()),
                    asset_id: None,
                },
                FileTreeRuntimeRow {
                    row_id: "asset-folder:mind".into(),
                    row_kind: "asset-folder".into(),
                    document_id: Some("root".into()),
                    asset_id: Some("mind".into()),
                },
                FileTreeRuntimeRow {
                    row_id: "asset:image".into(),
                    row_kind: "asset".into(),
                    document_id: Some("root".into()),
                    asset_id: Some("image".into()),
                },
            ],
            root_uri: Some("file:///tmp/mnote".into()),
        }
    }

    #[test]
    fn filetree_runtime_reducer_covers_selection_drag_drop_and_open_menu_intents() {
        let transition = FileTreeRuntimeState::default().reduce(
            &env(),
            FileTreeRuntimeAction::SelectRow {
                row_id: "doc:root".into(),
                modifiers: FileTreeSelectionModifiers::default(),
            },
        );
        assert!(transition
            .state
            .selection
            .selected_row_ids
            .contains("doc:root"));
        assert!(transition
            .outputs
            .contains(&FileTreeRuntimeOutput::DomPatch));

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::SelectRow {
                row_id: "asset:image".into(),
                modifiers: FileTreeSelectionModifiers {
                    shift_key: true,
                    ..FileTreeSelectionModifiers::default()
                },
            },
        );
        assert!(transition
            .state
            .selection
            .selected_row_ids
            .contains("index:root"));
        assert!(transition
            .state
            .selection
            .selected_row_ids
            .contains("asset-folder:mind"));
        assert!(transition
            .state
            .selection
            .selected_row_ids
            .contains("asset:image"));

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::ResolveDragRows {
                row_id: "asset:image".into(),
                has_external_files: false,
                alt_key: true,
            },
        );
        assert_eq!(
            transition.state.drag_row_ids,
            ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"])
        );
        assert_eq!(
            transition.state.drag_effect,
            Some(TreeShellDragEffect::Copy)
        );

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::UpdateDropTarget {
                row_id: Some("asset-folder:mind".into()),
            },
        );
        assert_eq!(
            transition.state.drop_target_row_id.as_deref(),
            Some("asset-folder:mind")
        );

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::OpenRow {
                row_id: "doc:root".into(),
            },
        );
        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::Document {
                    document_id: "root".into(),
                },
            },
        )));
        assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root"));

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::OpenRow {
                row_id: "index:root".into(),
            },
        );
        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::Index {
                    document_id: "root".into(),
                },
            },
        )));

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::OpenRow {
                row_id: "asset-folder:mind".into(),
            },
        );
        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::AssetFolder {
                    document_id: "root".into(),
                    asset_id: "mind".into(),
                },
            },
        )));

        let transition = transition.state.reduce(
            &env(),
            FileTreeRuntimeAction::ContextMenuRow {
                row_id: "asset:image".into(),
            },
        );
        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::ContextMenu {
                row_id: "asset:image".into(),
                target: FileTreeOpenTarget::Asset {
                    document_id: "root".into(),
                    asset_id: "image".into(),
                    resource_kind: None,
                },
            },
        )));
    }

    #[test]
    fn filetree_runtime_dispatches_drop_intents_and_clears_drag_state() {
        let state = FileTreeRuntimeState {
            drag_row_ids: ids(&["asset:image"]),
            drag_effect: Some(TreeShellDragEffect::Move),
            drop_target_row_id: Some("asset-folder:mind".into()),
            ..FileTreeRuntimeState::default()
        };

        let transition = state.reduce(
            &env(),
            FileTreeRuntimeAction::DispatchInternalDrop {
                target_row_id: Some("asset-folder:mind".into()),
                row_ids: ids(&["asset:image"]),
                copy: true,
            },
        );

        assert!(transition.state.drag_row_ids.is_empty());
        assert_eq!(transition.state.drag_effect, None);
        assert_eq!(transition.state.drop_target_row_id, None);
        assert!(transition
            .outputs
            .contains(&FileTreeRuntimeOutput::DomPatch));
        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::InternalDrop {
                target_row_id: Some("asset-folder:mind".into()),
                target: Some(FileTreeOpenTarget::AssetFolder {
                    document_id: "root".into(),
                    asset_id: "mind".into(),
                }),
                row_ids: ids(&["asset:image"]),
                copy: true,
            },
        )));

        let transition = FileTreeRuntimeState::default().reduce(
            &env(),
            FileTreeRuntimeAction::DispatchExternalDrop {
                target_row_id: Some("doc:root".into()),
                file_count: 2,
            },
        );

        assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::ExternalDrop {
                target_row_id: Some("doc:root".into()),
                target: Some(FileTreeOpenTarget::Document {
                    document_id: "root".into(),
                }),
                file_count: 2,
            },
        )));

        let rejected = FileTreeRuntimeState::default().reduce(
            &env(),
            FileTreeRuntimeAction::DispatchInternalDrop {
                target_row_id: Some("missing:target".into()),
                row_ids: ids(&["asset:image"]),
                copy: false,
            },
        );
        assert!(rejected.outputs.contains(&FileTreeRuntimeOutput::DomPatch));
        assert!(!rejected.outputs.iter().any(|output| matches!(
            output,
            FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
        )));
    }

    #[test]
    fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() {
        let state = FileTreeRuntimeState {
            active_row_id: Some("doc:root".into()),
            selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected(
                &["doc:root".to_string()],
            ),
            ..FileTreeRuntimeState::default()
        };

        let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext);
        assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root"));
        assert_eq!(
            focused.state.selection.focused_row_id.as_deref(),
            Some("index:root")
        );
        assert!(focused
            .state
            .selection
            .selected_row_ids
            .contains("doc:root"));

        let opened = focused
            .state
            .reduce(&env(), FileTreeRuntimeAction::OpenFocused);
        assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root"));
        assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::Index {
                    document_id: "root".into(),
                },
            },
        )));

        let rename = focused
            .state
            .reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused);
        assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::KeyboardCommand {
                command: FileTreeKeyboardCommand::Rename,
                row_ids: vec!["doc:root".into()],
                target_row_id: Some("index:root".into()),
            },
        )));
        let cut = focused
            .state
            .reduce(&env(), FileTreeRuntimeAction::CutSelection);
        assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::KeyboardCommand {
                command: FileTreeKeyboardCommand::Cut,
                row_ids: vec!["doc:root".into()],
                target_row_id: Some("index:root".into()),
            },
        )));
        let escaped = FileTreeRuntimeState {
            drag_row_ids: vec!["doc:root".into()],
            drag_effect: Some(TreeShellDragEffect::Move),
            drop_target_row_id: Some("index:root".into()),
            ..focused.state
        }
        .reduce(&env(), FileTreeRuntimeAction::Escape);
        assert!(escaped.state.drag_row_ids.is_empty());
        assert_eq!(escaped.state.drag_effect, None);
        assert_eq!(escaped.state.drop_target_row_id, None);
    }

    #[test]
    fn filetree_open_target_serializes_resource_identity_variants() {
        let asset = serde_json::to_value(FileTreeOpenTarget::Asset {
            document_id: "doc:page".into(),
            asset_id: "local-file:Page/report.docx".into(),
            resource_kind: Some("office".into()),
        })
        .expect("serialize asset target");
        assert_eq!(
            asset,
            serde_json::json!({
                "kind": "asset",
                "documentId": "doc:page",
                "assetId": "local-file:Page/report.docx",
                "resourceKind": "office"
            })
        );

        let local_file = serde_json::to_value(FileTreeOpenTarget::LocalFile {
            root_uri: "file:///tmp/mnote".into(),
            path: "Page/资源.ext".into(),
            resource_kind: Some("local_file".into()),
        })
        .expect("serialize local file target");
        assert_eq!(
            local_file,
            serde_json::json!({
                "kind": "localFile",
                "rootUri": "file:///tmp/mnote",
                "path": "Page/资源.ext",
                "resourceKind": "local_file"
            })
        );

        let directory = serde_json::to_value(FileTreeOpenTarget::Directory {
            root_uri: "file:///tmp/mnote".into(),
            path: "Page/assets".into(),
        })
        .expect("serialize directory target");
        assert_eq!(
            directory,
            serde_json::json!({
                "kind": "directory",
                "rootUri": "file:///tmp/mnote",
                "path": "Page/assets"
            })
        );
    }

    #[test]
    fn filetree_open_target_uses_environment_root_uri_for_local_targets() {
        let mut env = env();
        env.visible_row_ids = ids(&["local-file:Page/资源.ext", "local-dir:Page/assets"]);
        env.rows = vec![
            FileTreeRuntimeRow {
                row_id: "local-file:Page/资源.ext".into(),
                row_kind: "local-file".into(),
                document_id: None,
                asset_id: Some("Page/资源.ext".into()),
            },
            FileTreeRuntimeRow {
                row_id: "local-dir:Page/assets".into(),
                row_kind: "directory".into(),
                document_id: None,
                asset_id: Some("Page/assets".into()),
            },
        ];

        let open_file = FileTreeRuntimeState::default().reduce(
            &env,
            FileTreeRuntimeAction::OpenRow {
                row_id: "local-file:Page/资源.ext".into(),
            },
        );
        assert!(open_file.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::LocalFile {
                    root_uri: "file:///tmp/mnote".into(),
                    path: "Page/资源.ext".into(),
                    resource_kind: None,
                },
            },
        )));

        let open_dir = FileTreeRuntimeState::default().reduce(
            &env,
            FileTreeRuntimeAction::OpenRow {
                row_id: "local-dir:Page/assets".into(),
            },
        );
        assert!(open_dir.outputs.contains(&FileTreeRuntimeOutput::Intent(
            FileTreeIntentEvent::Open {
                target: FileTreeOpenTarget::Directory {
                    root_uri: "file:///tmp/mnote".into(),
                    path: "Page/assets".into(),
                },
            },
        )));
    }
}
