//! Pure UI state machine. Storage behavior is represented only by typed results.

use std::{collections::BTreeSet, time::Instant};

use ironstorage::{
    command::{
        CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
        Presentation, help_text, otp_version_text, version_text,
    },
    config::Config,
    crypto::KeyInfo,
    document::{DocumentError, EntryDocument, EntryFieldId},
    git::{GitConflict, GitProgressPhase, GitSnapshot},
    otp::OtpCodeValidity,
    presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
    read::{FindResults, GrepResults, TreeModel},
    repository::SecretBytes,
    write::WriteOutcome,
};

use crate::{
    action::{Action, WorkflowAction},
    command::{CommandInvocation, CommandLine, operation_name},
    editor::EntryEditor,
    search::GrepView,
    sidebar::{Sidebar, SidebarIntent},
    viewer::EntryViewer,
    workflow::{OtpFormKind, WorkflowForm, WorkflowInput, WorkflowSubmission},
};

#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Mode {
    Browser,
    Viewer,
    Editor,
    Dialog,
    Help,
    Command,
    Locked,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Transition {
    OpenEntry,
    EditEntry,
    CloseEntry,
    OpenDialog,
    OpenHelp,
    OpenCommand,
    Dismiss,
    Lock,
    Unlock,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneFocus {
    Sidebar,
    Main,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RequestToken {
    id: u64,
    generation: u64,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ClipboardPresentationId(pub(crate) u64);

#[derive(Clone, Copy, Debug)]
struct ClipboardPresentation {
    id: ClipboardPresentationId,
    deadline: Option<Instant>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupData {
    pub config: Config,
    pub tree: TreeModel,
    pub key: KeyInfo,
    pub keys: Vec<KeyInfo>,
}

#[derive(Debug)]
pub struct WorkflowSuccess {
    pub tree: Option<TreeModel>,
    pub selection: Option<(String, bool)>,
    pub entry: Option<String>,
    pub document: Option<Box<EntryDocument>>,
    pub grep: Option<GrepResults>,
    pub presentation: Option<SecretPresentation>,
    pub status: String,
}

#[derive(Debug)]
pub struct GitView {
    snapshot: GitSnapshot,
    message: String,
    conflicts: Vec<GitConflict>,
    details: Option<SecretBytes>,
}

#[derive(Debug)]
pub struct OtpDisplay {
    entry: String,
    field: Option<EntryFieldId>,
    code: SecretBytes,
    validity: OtpCodeValidity,
    remaining_seconds: Option<u64>,
}

#[derive(Debug)]
pub struct QrPopup {
    title: String,
    matrix: QrMatrix,
}

impl QrPopup {
    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn matrix(&self) -> &QrMatrix {
        &self.matrix
    }
}

#[derive(Debug)]
pub enum SecretPresentation {
    Clipboard(SecretBytes),
    Qr { title: String, matrix: QrMatrix },
}

impl OtpDisplay {
    pub fn entry(&self) -> &str {
        &self.entry
    }
    pub fn field(&self) -> Option<EntryFieldId> {
        self.field
    }
    pub fn code(&self) -> &SecretBytes {
        &self.code
    }
    pub fn remaining_seconds(&self) -> Option<u64> {
        self.remaining_seconds
    }
    pub fn counter(&self) -> Option<u64> {
        self.validity.counter()
    }
    pub fn period_seconds(&self) -> Option<u64> {
        self.validity.period()
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpPresentationTarget {
    Terminal,
    Clipboard,
    Qr,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OtpUiRequest {
    pub request: OtpRequest,
    pub confirmed_hotp: bool,
    pub field: Option<EntryFieldId>,
}

impl GitView {
    pub fn snapshot(&self) -> &GitSnapshot {
        &self.snapshot
    }
    pub fn message(&self) -> &str {
        &self.message
    }
    pub fn conflicts(&self) -> &[GitConflict] {
        &self.conflicts
    }
    pub fn details(&self) -> Option<&SecretBytes> {
        self.details.as_ref()
    }
}

#[derive(Debug)]
pub enum AsyncPayload {
    Startup(Box<StartupData>),
    Refreshed(TreeModel),
    Filtered {
        query: String,
        results: FindResults,
    },
    Found {
        query: String,
        results: FindResults,
    },
    DocumentLoaded {
        entry: String,
        document: Box<EntryDocument>,
    },
    ClipboardStarted {
        presentation: ClipboardPresentationId,
        deadline: Instant,
    },
    ClipboardFinished {
        presentation: ClipboardPresentationId,
        result: Result<ClipboardDisposition, ClipboardError>,
    },
    GeneratedField {
        target: EntryFieldId,
        password: SecretBytes,
    },
    DocumentSaveFinished {
        entry: String,
        editor: Box<EntryEditor>,
        result: Result<WriteOutcome, EditorSaveFailure>,
    },
    WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
    GitProgress(GitProgressPhase),
    GitFinished {
        snapshot: Box<GitSnapshot>,
        tree: Option<TreeModel>,
        message: String,
        conflicts: Vec<GitConflict>,
        details: Option<SecretBytes>,
    },
    OtpCodeFinished {
        entry: String,
        field: Option<EntryFieldId>,
        code: SecretBytes,
        validity: OtpCodeValidity,
        observed_at: u64,
        clipboard: bool,
        tree: Option<TreeModel>,
    },
    OtpUriFinished {
        entry: String,
        presentation: OtpPresentationTarget,
        payload: SecretBytes,
        qr: Option<QrMatrix>,
    },
    OtpValidated,
    SecretPresented {
        status: String,
        presentation: SecretPresentation,
    },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EditorSaveFailureKind {
    Conflict,
    Unchanged,
    Storage,
}

#[derive(Debug)]
pub struct EditorSaveFailure {
    kind: EditorSaveFailureKind,
    message: String,
}

impl EditorSaveFailure {
    pub fn new(kind: EditorSaveFailureKind, message: String) -> Self {
        Self { kind, message }
    }

    pub fn kind(&self) -> EditorSaveFailureKind {
        self.kind
    }

    pub fn message(&self) -> &str {
        &self.message
    }
}

#[derive(Debug)]
pub struct AsyncResult {
    pub token: RequestToken,
    pub payload: Result<AsyncPayload, String>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultDisposition {
    Applied,
    Stale,
}

#[derive(Debug)]
pub enum AppEffect {
    None,
    RefreshTree,
    AuthenticateEntry(String),
    CopyFocused {
        presentation: ClipboardPresentationId,
        value: SecretBytes,
    },
    GenerateField(EntryFieldId),
    SaveDocument {
        config: Box<Config>,
        entry: String,
        editor: Box<EntryEditor>,
    },
    AuthenticateWorkflow(Box<WorkflowSubmission>),
    AuthenticateGit(ironstorage::command::GitRequest),
    CancelGit,
    ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
    AuthenticateOtp(OtpUiRequest),
    AuthenticateShow(ironstorage::command::ShowRequest),
    RunCommand(CommandRequest),
    ManualLock,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandOpenTarget {
    Viewer,
    Editor,
}

#[derive(Debug)]
pub struct App {
    mode: Mode,
    suspended_mode: Option<Mode>,
    focus: PaneFocus,
    status: String,
    config: Option<Config>,
    default_key: Option<KeyInfo>,
    available_keys: Vec<KeyInfo>,
    sidebar: Sidebar,
    selected_entry: Option<String>,
    viewer: Option<EntryViewer>,
    editor: Option<EntryEditor>,
    discard_confirmation: bool,
    command_line: CommandLine,
    command_help: Option<String>,
    command_open_target: Option<CommandOpenTarget>,
    editor_generation_pending: Option<EntryFieldId>,
    authentication_pending: Option<String>,
    workflow: Option<WorkflowForm>,
    workflow_pending: bool,
    grep_view: Option<GrepView>,
    git_view: Option<GitView>,
    git_pending: bool,
    otp_display: Option<OtpDisplay>,
    qr_popup: Option<QrPopup>,
    uri_popup: Option<SecretBytes>,
    otp_pending: bool,
    hotp_confirmation: Option<OtpUiRequest>,
    clipboard_request: Option<SecretBytes>,
    clipboard_presentation: Option<ClipboardPresentation>,
    next_clipboard_presentation: u64,
    remaining_lease: Option<std::time::Duration>,
    terminal_size: (u16, u16),
    ticks: u64,
    should_quit: bool,
    generation: u64,
    next_request_id: u64,
    pending: BTreeSet<u64>,
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

impl App {
    pub fn new() -> Self {
        Self {
            mode: Mode::Browser,
            suspended_mode: None,
            focus: PaneFocus::Sidebar,
            status: "Starting…".to_owned(),
            config: None,
            default_key: None,
            available_keys: Vec::new(),
            sidebar: Sidebar::default(),
            selected_entry: None,
            viewer: None,
            editor: None,
            discard_confirmation: false,
            command_line: CommandLine::default(),
            command_help: None,
            command_open_target: None,
            editor_generation_pending: None,
            authentication_pending: None,
            workflow: None,
            workflow_pending: false,
            grep_view: None,
            git_view: None,
            git_pending: false,
            otp_display: None,
            qr_popup: None,
            uri_popup: None,
            otp_pending: false,
            hotp_confirmation: None,
            clipboard_request: None,
            clipboard_presentation: None,
            next_clipboard_presentation: 0,
            remaining_lease: None,
            terminal_size: (0, 0),
            ticks: 0,
            should_quit: false,
            generation: 0,
            next_request_id: 0,
            pending: BTreeSet::new(),
        }
    }

    pub fn mode(&self) -> Mode {
        self.mode
    }

    pub fn help_context_mode(&self) -> Mode {
        if self.mode == Mode::Help {
            self.suspended_mode.unwrap_or(Mode::Browser)
        } else {
            self.mode
        }
    }

    pub fn command_context_mode(&self) -> Mode {
        if self.mode == Mode::Command {
            self.suspended_mode.unwrap_or(Mode::Browser)
        } else {
            self.mode
        }
    }

    pub fn focus(&self) -> PaneFocus {
        self.focus
    }

    pub fn status(&self) -> &str {
        &self.status
    }

    pub fn config(&self) -> Option<&Config> {
        self.config.as_ref()
    }

    pub fn sidebar(&self) -> &Sidebar {
        &self.sidebar
    }

    pub fn sidebar_mut(&mut self) -> &mut Sidebar {
        &mut self.sidebar
    }

    pub fn selected_entry(&self) -> Option<&str> {
        self.selected_entry.as_deref()
    }

    pub fn viewer(&self) -> Option<&EntryViewer> {
        self.viewer.as_ref()
    }

    pub fn editor(&self) -> Option<&EntryEditor> {
        self.editor.as_ref()
    }

    pub fn discard_confirmation(&self) -> bool {
        self.discard_confirmation
    }

    pub fn command_line(&self) -> &CommandLine {
        &self.command_line
    }

    pub fn command_help(&self) -> Option<&str> {
        self.command_help.as_deref()
    }

    pub fn default_key(&self) -> Option<&KeyInfo> {
        self.default_key.as_ref()
    }

    pub fn authentication_pending(&self) -> bool {
        self.authentication_pending.is_some() || self.workflow_pending
    }

    pub fn workflow(&self) -> Option<&WorkflowForm> {
        self.workflow.as_ref()
    }

    pub fn grep_view(&self) -> Option<&GrepView> {
        self.grep_view.as_ref()
    }

    pub fn git_view(&self) -> Option<&GitView> {
        self.git_view.as_ref()
    }

    pub fn git_pending(&self) -> bool {
        self.git_pending
    }

    pub fn otp_display(&self) -> Option<&OtpDisplay> {
        self.otp_display.as_ref().filter(|display| {
            self.selected_entry
                .as_deref()
                .is_some_and(|entry| entry == display.entry())
        })
    }
    pub fn qr_popup(&self) -> Option<&QrPopup> {
        self.qr_popup.as_ref()
    }
    pub fn uri_popup(&self) -> Option<&SecretBytes> {
        self.uri_popup.as_ref()
    }
    pub fn otp_pending(&self) -> bool {
        self.otp_pending
    }
    pub fn hotp_confirmation(&self) -> bool {
        self.hotp_confirmation.is_some()
    }
    pub fn take_clipboard_effect(&mut self) -> Option<AppEffect> {
        let value = self.clipboard_request.take()?;
        Some(self.begin_clipboard_presentation(value))
    }

    pub fn clipboard_remaining_seconds(&self) -> Option<u64> {
        self.clipboard_remaining_seconds_at(Instant::now())
    }

    fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option<u64> {
        let deadline = self.clipboard_presentation?.deadline?;
        let milliseconds = deadline.saturating_duration_since(now).as_millis();
        Some(u64::try_from(milliseconds.div_ceil(1_000)).unwrap_or(u64::MAX))
    }

    pub fn clipboard_pending(&self) -> bool {
        self.clipboard_presentation.is_some()
    }

    pub fn begin_git_operation(&mut self, label: &str) {
        self.git_pending = true;
        self.status = format!("{label} queued for secure-storage authentication…");
    }

    pub fn remaining_lease(&self) -> Option<std::time::Duration> {
        self.remaining_lease
    }

    pub fn terminal_size(&self) -> (u16, u16) {
        self.terminal_size
    }

    pub fn ticks(&self) -> u64 {
        self.ticks
    }

    pub fn is_busy(&self) -> bool {
        !self.pending.is_empty()
    }

    pub fn should_quit(&self) -> bool {
        self.should_quit
    }

    pub fn resize(&mut self, width: u16, height: u16) {
        self.terminal_size = (width, height);
        let content_height = height.saturating_sub(3) as usize;
        let sidebar_height = if width < 80 {
            content_height.saturating_mul(40) / 100
        } else {
            content_height
        };
        self.sidebar
            .set_viewport_height(sidebar_height.saturating_sub(2));
    }

    pub fn tick(&mut self) {
        self.ticks = self.ticks.wrapping_add(1);
    }

    pub fn observe_time(&mut self, unix_seconds: u64) {
        if let Some(display) = self.otp_display.as_mut() {
            display.remaining_seconds = display.validity.remaining_at(unix_seconds);
        }
    }

    pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> {
        if self.otp_pending || self.mode != Mode::Viewer {
            return None;
        }
        let viewer = self.viewer.as_ref()?;
        let entry = self.selected_entry.clone()?;
        let field = if let Some(display) = self
            .otp_display
            .as_ref()
            .filter(|display| display.entry == entry)
        {
            match display.remaining_seconds {
                Some(0) => {
                    let field_id = display.field?;
                    viewer.document().fields().iter().find(|field| {
                        field.id() == field_id
                            && field
                                .metadata()
                                .otp()
                                .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
                    })?
                }
                Some(_) | None => return None,
            }
        } else {
            viewer.document().fields().iter().find(|field| {
                field
                    .metadata()
                    .otp()
                    .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
            })?
        };
        self.otp_pending = true;
        Some((entry, field.id()))
    }

    pub fn begin_request(&mut self) -> RequestToken {
        let token = RequestToken {
            id: self.next_request_id,
            generation: self.generation,
        };
        self.next_request_id = self.next_request_id.wrapping_add(1);
        self.pending.insert(token.id);
        token
    }

    pub fn begin_latest_request(&mut self) -> RequestToken {
        self.invalidate_requests();
        self.begin_request()
    }

    pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
        if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
            return ResultDisposition::Stale;
        }
        if !matches!(
            result.payload,
            Ok(AsyncPayload::GitProgress(_) | AsyncPayload::ClipboardStarted { .. })
        ) {
            self.pending.remove(&result.token.id);
        }
        match result.payload {
            Ok(AsyncPayload::Startup(startup)) => {
                self.status = format!("Vault: {}", startup.config.vault().display());
                self.sidebar.replace_tree(&startup.tree);
                self.default_key = Some(startup.key);
                self.available_keys = startup.keys;
                self.config = Some(startup.config);
            }
            Ok(AsyncPayload::Refreshed(tree)) => {
                self.sidebar.replace_tree(&tree);
                self.grep_view = None;
                self.focus = PaneFocus::Sidebar;
                self.status = "Password store refreshed".to_owned();
            }
            Ok(AsyncPayload::Filtered { query, results }) => {
                if !self.sidebar.apply_filter_results(&query, &results) {
                    return ResultDisposition::Stale;
                }
                self.status = format!("Filter: {query} ({} matches)", results.matches().len());
            }
            Ok(AsyncPayload::Found { query, results }) => {
                let count = results.matches().len();
                self.sidebar.apply_find_results(&query, &results);
                self.grep_view = None;
                self.focus = PaneFocus::Sidebar;
                self.status = if count == 0 {
                    format!("Find: {query} (no matches)")
                } else {
                    format!("Find: {query} ({count} matches)")
                };
            }
            Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
                if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
                    return ResultDisposition::Stale;
                }
                let field_count = document.fields().len();
                if self.command_open_target.take() == Some(CommandOpenTarget::Editor) {
                    self.editor = Some(EntryEditor::new(*document));
                    self.viewer = None;
                    self.mode = Mode::Editor;
                    self.status = format!("Editing {entry} ({field_count} fields)");
                } else {
                    self.viewer = Some(EntryViewer::new(*document));
                    self.status = format!("Opened {entry} ({field_count} fields)");
                }
                self.focus = PaneFocus::Main;
            }
            Ok(AsyncPayload::ClipboardStarted {
                presentation,
                deadline,
            }) => {
                if let Some(active) = self
                    .clipboard_presentation
                    .as_mut()
                    .filter(|active| active.id == presentation)
                {
                    active.deadline = Some(deadline);
                    self.status = "Secret copied".to_owned();
                }
            }
            Ok(AsyncPayload::ClipboardFinished {
                presentation,
                result,
            }) => {
                if self
                    .clipboard_presentation
                    .is_none_or(|active| active.id != presentation)
                {
                    return ResultDisposition::Applied;
                }
                self.clipboard_presentation = None;
                self.status = match result {
                    Ok(ClipboardDisposition::RestoredPrevious) => {
                        "Clipboard restored to its previous value".to_owned()
                    }
                    Ok(ClipboardDisposition::Cleared) => "Clipboard secret cleared".to_owned(),
                    Ok(ClipboardDisposition::PreservedNewer) => {
                        "Clipboard changed; the newer value was preserved".to_owned()
                    }
                    Err(error) => format!("Clipboard presentation failed: {error}"),
                };
            }
            Ok(AsyncPayload::GeneratedField { target, password }) => {
                if !self.editor_context_active()
                    || self.editor_generation_pending.take() != Some(target)
                {
                    return ResultDisposition::Stale;
                }
                let Some(editor) = self.editor.as_mut() else {
                    return ResultDisposition::Stale;
                };
                match editor.apply_generated(target, password) {
                    Ok(()) => {
                        self.status = "Generated password applied to focused field".to_owned()
                    }
                    Err(error) => self.status = error.to_string(),
                }
            }
            Ok(AsyncPayload::DocumentSaveFinished {
                entry,
                editor,
                result,
            }) => {
                if !self.editor_context_active() || self.selected_entry.as_deref() != Some(&entry) {
                    return ResultDisposition::Stale;
                }
                match result {
                    Ok(_) => {
                        self.editor = None;
                        self.selected_entry = None;
                        self.suspended_mode = None;
                        self.discard_confirmation = false;
                        self.editor_generation_pending = None;
                        self.mode = Mode::Browser;
                        self.focus = PaneFocus::Sidebar;
                        self.status = format!("Saved {entry}");
                    }
                    Err(failure) => match failure.kind() {
                        EditorSaveFailureKind::Unchanged => match (*editor).into_document() {
                            Ok(document) => {
                                self.viewer = Some(EntryViewer::new(document));
                                self.suspended_mode = None;
                                self.discard_confirmation = false;
                                self.mode = Mode::Viewer;
                                self.focus = PaneFocus::Main;
                                self.status = "No changes to save".to_owned();
                            }
                            Err(failed) => {
                                let (editor, error) = *failed;
                                self.editor = Some(editor);
                                self.status = error.to_string();
                            }
                        },
                        EditorSaveFailureKind::Conflict => {
                            self.editor = Some(*editor);
                            self.status = format!(
                                "Concurrent change detected: {}. Draft retained; Esc offers discard.",
                                failure.message()
                            );
                        }
                        EditorSaveFailureKind::Storage => {
                            self.editor = Some(*editor);
                            self.status =
                                format!("Save failed: {}. Draft retained.", failure.message());
                        }
                    },
                }
            }
            Ok(AsyncPayload::WorkflowFinished(result)) => {
                self.workflow_pending = false;
                match result {
                    Ok(success) => {
                        if let Some(tree) = success.tree.as_ref() {
                            self.sidebar.replace_tree(tree);
                        }
                        self.workflow = None;
                        self.suspended_mode = None;
                        if let Some(grep) = success.grep {
                            self.selected_entry = None;
                            self.viewer = None;
                            self.editor = None;
                            self.grep_view = Some(GrepView::new(grep));
                            self.mode = Mode::Browser;
                            self.focus = PaneFocus::Main;
                        } else if let (Some(entry), Some(document)) =
                            (success.entry, success.document)
                        {
                            self.selected_entry = Some(entry);
                            self.viewer = Some(EntryViewer::new(*document));
                            self.editor = None;
                            self.grep_view = None;
                            self.mode = Mode::Viewer;
                            self.focus = PaneFocus::Main;
                        } else {
                            self.selected_entry = None;
                            self.viewer = None;
                            self.editor = None;
                            self.grep_view = None;
                            self.mode = Mode::Browser;
                            self.focus = PaneFocus::Sidebar;
                        }
                        if let Some((path, directory)) = success.selection {
                            self.sidebar.select_path(&path, directory);
                        }
                        if let Some(presentation) = success.presentation {
                            self.apply_secret_presentation(presentation);
                        }
                        self.status = success.status;
                    }
                    Err(error) => {
                        self.status = format!("Workflow failed: {error}. Form retained.");
                    }
                }
            }
            Ok(AsyncPayload::GitProgress(phase)) => {
                self.status = format!("Git {}… (Esc cancels)", git_phase_name(phase));
            }
            Ok(AsyncPayload::GitFinished {
                snapshot,
                tree,
                message,
                conflicts,
                details,
            }) => {
                self.git_pending = false;
                if let Some(tree) = tree {
                    self.sidebar.replace_tree(&tree);
                }
                self.git_view = Some(GitView {
                    snapshot: *snapshot,
                    message: message.clone(),
                    conflicts,
                    details,
                });
                self.grep_view = None;
                self.selected_entry = None;
                self.viewer = None;
                self.editor = None;
                self.mode = Mode::Browser;
                self.focus = PaneFocus::Main;
                self.status = message;
            }
            Ok(AsyncPayload::OtpCodeFinished {
                entry,
                field,
                code,
                validity,
                observed_at,
                clipboard,
                tree,
            }) => {
                self.otp_pending = false;
                if let Some(tree) = tree {
                    self.sidebar.replace_tree(&tree);
                }
                if clipboard {
                    self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec()));
                }
                self.status = if let Some(counter) = validity.counter() {
                    format!("Generated and committed HOTP counter {counter}")
                } else {
                    "TOTP code refreshed".to_owned()
                };
                self.otp_display = Some(OtpDisplay {
                    entry,
                    field,
                    code,
                    validity,
                    remaining_seconds: validity.remaining_at(observed_at),
                });
                self.hotp_confirmation = None;
            }
            Ok(AsyncPayload::OtpUriFinished {
                entry,
                presentation,
                payload,
                qr,
            }) => {
                self.otp_pending = false;
                self.status = format!("Presented OTP URI for {entry}");
                match presentation {
                    OtpPresentationTarget::Terminal => {
                        self.qr_popup = None;
                        self.otp_display = None;
                        self.uri_popup = Some(payload);
                    }
                    OtpPresentationTarget::Clipboard => {
                        self.clipboard_request = Some(payload);
                    }
                    OtpPresentationTarget::Qr => {
                        self.uri_popup = None;
                        self.qr_popup = qr.map(|matrix| QrPopup {
                            title: "OTP QR".to_owned(),
                            matrix,
                        });
                    }
                }
            }
            Ok(AsyncPayload::OtpValidated) => {
                self.otp_pending = false;
                self.status = "OTP URI is valid".to_owned();
            }
            Ok(AsyncPayload::SecretPresented {
                status,
                presentation,
            }) => {
                self.apply_secret_presentation(presentation);
                self.status = status;
            }
            Err(error) => {
                self.git_pending = false;
                self.otp_pending = false;
                self.status = error;
                self.editor_generation_pending = None;
                self.command_open_target = None;
                if self.mode == Mode::Viewer && self.viewer.is_none() {
                    self.mode = Mode::Browser;
                    self.focus = PaneFocus::Sidebar;
                    self.selected_entry = None;
                }
            }
        }
        ResultDisposition::Applied
    }

    pub fn dispatch(&mut self, action: Action) -> AppEffect {
        if self.editor_generation_pending.is_some()
            && matches!(
                action,
                Action::FocusNext
                    | Action::FocusPrevious
                    | Action::BeginInput
                    | Action::SaveEditor
                    | Action::AddField
                    | Action::RemoveField
                    | Action::MoveFieldUp
                    | Action::MoveFieldDown
                    | Action::Generate
            )
        {
            self.status = "Wait for password generation to finish".to_owned();
            return AppEffect::None;
        }
        match action {
            Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer | Mode::Locked) => {
                self.should_quit = true;
            }
            Action::Help => {
                self.command_help = None;
                self.transition(Transition::OpenHelp);
            }
            Action::Command => {
                self.command_line.clear();
                self.transition(Transition::OpenCommand);
            }
            Action::Cancel => {
                if self.qr_popup.is_some() || self.uri_popup.is_some() {
                    self.qr_popup = None;
                    self.uri_popup = None;
                    self.status = "OTP presentation closed".to_owned();
                } else if self.hotp_confirmation.is_some() {
                    self.hotp_confirmation = None;
                    self.status = "HOTP generation cancelled; counter unchanged".to_owned();
                    self.transition(Transition::Dismiss);
                } else if self.git_pending {
                    self.status = "Cancelling Git operation…".to_owned();
                    return AppEffect::CancelGit;
                } else if self.git_view.is_some() && self.mode == Mode::Browser {
                    self.git_view = None;
                    self.focus = PaneFocus::Sidebar;
                    self.status = "Git status closed".to_owned();
                } else if self.grep_view.is_some() && self.mode == Mode::Browser {
                    self.grep_view = None;
                    self.focus = PaneFocus::Sidebar;
                    self.status = "Decrypted search results closed".to_owned();
                } else if self.mode == Mode::Editor {
                    self.cancel_editor();
                } else if self.mode == Mode::Dialog && self.discard_confirmation {
                    self.keep_editing();
                } else if self.mode == Mode::Dialog && self.workflow.is_some() {
                    self.cancel_workflow();
                } else {
                    if self.mode == Mode::Command {
                        self.command_line.clear();
                        self.status = "Command cancelled".to_owned();
                    }
                    self.transition(Transition::Dismiss);
                }
            }
            Action::Lock => {
                self.transition(Transition::Lock);
                return AppEffect::ManualLock;
            }
            Action::Unlock => {
                self.transition(Transition::Unlock);
            }
            Action::Refresh => return AppEffect::RefreshTree,
            Action::Next if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
                self.grep_view.as_mut().expect("checked grep view").next();
            }
            Action::Previous if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
                self.grep_view
                    .as_mut()
                    .expect("checked grep view")
                    .previous();
            }
            Action::Next if self.mode == Mode::Viewer => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.focus_next();
                }
            }
            Action::Previous if self.mode == Mode::Viewer => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.focus_previous();
                }
            }
            Action::Next => self.sidebar.move_next(),
            Action::Previous => self.sidebar.move_previous(),
            Action::PageDown => self.sidebar.page_down(),
            Action::PageUp => self.sidebar.page_up(),
            Action::First => self.sidebar.move_first(),
            Action::Last => self.sidebar.move_last(),
            Action::Parent => self.sidebar.collapse_or_parent(),
            Action::Child => self.sidebar.move_child(),
            Action::Activate => {
                if self.grep_view.is_some() && self.focus == PaneFocus::Main {
                    let entry = self
                        .grep_view
                        .as_ref()
                        .and_then(GrepView::selected_entry)
                        .map(|entry| entry.path().to_string());
                    if let Some(entry) = entry {
                        self.sidebar.select_path(&entry, false);
                        self.grep_view = None;
                        self.authentication_pending = Some(entry.clone());
                        self.status = format!("Authenticating to open search result {entry}…");
                        return AppEffect::AuthenticateEntry(entry);
                    }
                    self.status = "The decrypted search has no results".to_owned();
                    return AppEffect::None;
                }
                if self.authentication_pending.is_none()
                    && let SidebarIntent::OpenEntry(path) = self.sidebar.activate()
                {
                    self.authentication_pending = Some(path.clone());
                    self.status = if self
                        .default_key
                        .as_ref()
                        .is_some_and(KeyInfo::requires_passphrase)
                    {
                        "Authenticating through secure storage for the OpenPGP passphrase…"
                            .to_owned()
                    } else {
                        "Authenticating through secure storage…".to_owned()
                    };
                    return AppEffect::AuthenticateEntry(path);
                }
            }
            Action::Filter => {
                self.grep_view = None;
                self.focus = PaneFocus::Sidebar;
                self.sidebar.begin_filter();
            }
            Action::NextMatch => self.sidebar.next_match(),
            Action::PreviousMatch => self.sidebar.previous_match(),
            Action::FocusNext if self.mode == Mode::Viewer => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.focus_next();
                }
            }
            Action::FocusPrevious if self.mode == Mode::Viewer => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.focus_previous();
                }
            }
            Action::FocusNext if self.mode == Mode::Editor => {
                let result = self.editor.as_mut().map(EntryEditor::focus_next);
                self.report_editor_result(result);
            }
            Action::FocusPrevious if self.mode == Mode::Editor => {
                let result = self.editor.as_mut().map(EntryEditor::focus_previous);
                self.report_editor_result(result);
            }
            Action::FocusNext | Action::FocusPrevious => {
                self.focus = match self.focus {
                    PaneFocus::Sidebar => PaneFocus::Main,
                    PaneFocus::Main => PaneFocus::Sidebar,
                };
            }
            Action::Copy => {
                if let Some(viewer) = self.viewer.as_ref() {
                    match viewer.copy_focused() {
                        Ok(value) => {
                            return self.begin_clipboard_presentation(value);
                        }
                        Err(error) => self.status = error.to_string(),
                    }
                }
            }
            Action::ScrollDown => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.scroll_down(5);
                }
            }
            Action::ScrollUp => {
                if let Some(viewer) = self.viewer.as_mut() {
                    viewer.scroll_up(5);
                }
            }
            Action::CloseEntry => {
                self.transition(Transition::CloseEntry);
            }
            Action::EditEntry => {
                if let Some(viewer) = self.viewer.take() {
                    self.editor = Some(EntryEditor::new(viewer.into_document()));
                    self.transition(Transition::EditEntry);
                    self.status = "Structured editor: i edits, C-s saves, Esc cancels".to_owned();
                }
            }
            Action::BeginInput => {
                if let Some(editor) = self.editor.as_mut() {
                    editor.begin_input();
                    self.status = "Editing raw structured field; Esc stops input".to_owned();
                }
            }
            Action::SaveEditor => return self.begin_editor_save(),
            Action::AddField => {
                let result = self.editor.as_mut().map(EntryEditor::add_after_focused);
                self.report_editor_result(result);
            }
            Action::RemoveField => {
                let result = self.editor.as_mut().map(EntryEditor::remove_focused);
                self.report_editor_result(result);
            }
            Action::MoveFieldUp => {
                let result = self.editor.as_mut().map(EntryEditor::move_focused_up);
                self.report_editor_result(result);
            }
            Action::MoveFieldDown => {
                let result = self.editor.as_mut().map(EntryEditor::move_focused_down);
                self.report_editor_result(result);
            }
            Action::Generate => {
                if self.editor_generation_pending.is_none()
                    && let Some(editor) = self.editor.as_mut()
                {
                    match editor.generation_target() {
                        Ok(Some(target)) => {
                            self.editor_generation_pending = Some(target);
                            self.status = "Generating password for focused field…".to_owned();
                            return AppEffect::GenerateField(target);
                        }
                        Ok(None) => {
                            self.status = "Focused field is not a password field".to_owned();
                        }
                        Err(error) => self.status = error.to_string(),
                    }
                }
            }
            Action::ConfirmDiscard => {
                if let Some(mut request) = self.hotp_confirmation.take() {
                    request.confirmed_hotp = true;
                    self.transition(Transition::Dismiss);
                    self.otp_pending = true;
                    return AppEffect::AuthenticateOtp(request);
                } else if self.discard_confirmation {
                    self.discard_editor();
                }
            }
            Action::KeepEditing => {
                if self.hotp_confirmation.take().is_some() {
                    self.transition(Transition::Dismiss);
                    self.status = "HOTP generation cancelled; counter unchanged".to_owned();
                } else if self.discard_confirmation {
                    self.keep_editing();
                }
            }
            Action::Initialize
            | Action::InsertEntry
            | Action::GenerateEntry
            | Action::Grep
            | Action::ImportKdbx
            | Action::RemoveEntry
            | Action::MoveEntry
            | Action::CopyEntry
            | Action::GitPull
            | Action::GitPush => {
                let workflow = action.workflow().expect("matched workflow action");
                if matches!(
                    workflow,
                    WorkflowAction::Initialize
                        | WorkflowAction::InsertEntry
                        | WorkflowAction::GenerateEntry
                        | WorkflowAction::Grep
                        | WorkflowAction::ImportKdbx
                        | WorkflowAction::RemoveEntry
                        | WorkflowAction::MoveEntry
                        | WorkflowAction::CopyEntry
                ) {
                    self.open_workflow(workflow, None);
                    return AppEffect::None;
                }
                self.status = format!("Selected {} workflow", workflow_label(workflow));
                self.git_pending = true;
                return AppEffect::AuthenticateGit(match workflow {
                    WorkflowAction::GitPull => ironstorage::command::GitRequest::Pull {
                        remote: None,
                        branch: None,
                    },
                    WorkflowAction::GitPush => ironstorage::command::GitRequest::Push {
                        remote: None,
                        branch: None,
                    },
                    _ => unreachable!("only Git workflows reach this branch"),
                });
            }
            Action::Quit => {}
            Action::OtpCode
            | Action::OtpCopyCode
            | Action::OtpUri
            | Action::OtpCopyUri
            | Action::OtpQr => {
                let Some(entry) = self.selected_entry.clone() else {
                    self.status = "Select an OTP entry first".to_owned();
                    return AppEffect::None;
                };
                let Some((field, kind)) = self
                    .viewer
                    .as_ref()
                    .and_then(EntryViewer::focused_field)
                    .and_then(|field| field.metadata().otp().map(|otp| (field.id(), otp.kind())))
                else {
                    self.status = "Focus an OTP field first".to_owned();
                    return AppEffect::None;
                };
                let request = match action {
                    Action::OtpCode | Action::OtpCopyCode => OtpRequest::Code(OtpCodeRequest {
                        entry,
                        clipboard: action == Action::OtpCopyCode,
                    }),
                    Action::OtpUri | Action::OtpCopyUri | Action::OtpQr => {
                        OtpRequest::Uri(OtpUriRequest {
                            entry,
                            presentation: match action {
                                Action::OtpUri => OtpUriPresentation::Terminal,
                                Action::OtpCopyUri => OtpUriPresentation::Clipboard,
                                Action::OtpQr => OtpUriPresentation::QrCode,
                                _ => unreachable!(),
                            },
                        })
                    }
                    _ => unreachable!(),
                };
                let ui_request = OtpUiRequest {
                    request,
                    confirmed_hotp: false,
                    field: Some(field),
                };
                if matches!(ui_request.request, OtpRequest::Code(_))
                    && kind == ironstorage::otp::OtpKind::Hotp
                {
                    self.hotp_confirmation = Some(ui_request);
                    self.transition(Transition::OpenDialog);
                    self.status = "Generate HOTP and commit the advanced counter? y/n".to_owned();
                    return AppEffect::None;
                } else {
                    self.otp_pending = true;
                    return AppEffect::AuthenticateOtp(ui_request);
                }
            }
            Action::OtpInsert | Action::OtpAppend | Action::OtpValidate => {
                let (kind, entry) = match action {
                    Action::OtpInsert => (OtpFormKind::Insert, None),
                    Action::OtpAppend => (OtpFormKind::Append, self.selected_entry.clone()),
                    Action::OtpValidate => (OtpFormKind::Validate, None),
                    _ => unreachable!(),
                };
                self.workflow = Some(WorkflowForm::otp(kind, entry, false));
                self.transition(Transition::OpenDialog);
                self.status = "OTP form: Tab navigates, C-s submits, Esc cancels".to_owned();
                return AppEffect::None;
            }
        }
        AppEffect::None
    }

    pub fn editor_input_active(&self) -> bool {
        self.mode == Mode::Editor
            && self.editor_generation_pending.is_none()
            && self
                .editor
                .as_ref()
                .is_some_and(EntryEditor::is_input_active)
    }

    pub fn handle_workflow_input(
        &mut self,
        code: crossterm::event::KeyCode,
        modifiers: crossterm::event::KeyModifiers,
    ) -> Option<AppEffect> {
        if self.mode != Mode::Dialog || self.workflow.is_none() {
            return None;
        }
        if self.workflow_pending {
            if code == crossterm::event::KeyCode::Esc {
                self.status = "Wait for the active workflow to finish".to_owned();
            }
            return Some(AppEffect::None);
        }
        let input = self.workflow.as_mut()?.handle_key(code, modifiers);
        match input {
            WorkflowInput::Consumed => Some(AppEffect::None),
            WorkflowInput::Cancel => {
                self.cancel_workflow();
                Some(AppEffect::None)
            }
            WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
            WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
                Ok(submission) => {
                    self.workflow_pending = true;
                    self.status = "Authenticating before applying the workflow…".to_owned();
                    Some(AppEffect::AuthenticateWorkflow(Box::new(submission)))
                }
                Err(error) => {
                    self.status = format!("Validation: {error}");
                    Some(AppEffect::None)
                }
            },
        }
    }

    pub fn handle_command_input(
        &mut self,
        code: crossterm::event::KeyCode,
        modifiers: crossterm::event::KeyModifiers,
    ) -> Option<AppEffect> {
        use crossterm::event::{KeyCode, KeyModifiers};

        if self.mode != Mode::Command {
            return None;
        }
        let control = modifiers.contains(KeyModifiers::CONTROL);
        match (code, control) {
            (KeyCode::Esc, _) => return Some(self.dispatch(Action::Cancel)),
            (KeyCode::Enter, _) => return Some(self.submit_command()),
            (KeyCode::Char('l' | 'z'), true) => return Some(self.dispatch(Action::Lock)),
            (KeyCode::Tab, _) => {
                let paths = self.sidebar.completion_paths();
                if !self.command_line.complete(&paths, false) {
                    self.status = "No command completion is available".to_owned();
                }
            }
            (KeyCode::BackTab, _) => {
                let paths = self.sidebar.completion_paths();
                if !self.command_line.complete(&paths, true) {
                    self.status = "No command completion is available".to_owned();
                }
            }
            (KeyCode::Up, _) => self.command_line.previous_history(),
            (KeyCode::Down, _) => self.command_line.next_history(),
            (KeyCode::Left, _) => self.command_line.move_left(),
            (KeyCode::Right, _) => self.command_line.move_right(),
            (KeyCode::Home, _) | (KeyCode::Char('a'), true) => self.command_line.move_home(),
            (KeyCode::End, _) | (KeyCode::Char('e'), true) => self.command_line.move_end(),
            (KeyCode::Backspace, _) => self.command_line.backspace(),
            (KeyCode::Delete, _) => self.command_line.delete(),
            (KeyCode::Char(character), false) => self.command_line.insert(character),
            _ => return Some(AppEffect::None),
        }
        Some(AppEffect::None)
    }

    /// Insert bracketed paste as inert text into the active input surface.
    /// Control characters are rejected so paste can never submit a command or
    /// silently create additional password-store lines.
    pub fn handle_paste(&mut self, text: &str) -> bool {
        if text.chars().any(char::is_control) {
            self.status = "Paste rejected: control characters are not accepted".to_owned();
            return matches!(
                self.mode,
                Mode::Command | Mode::Editor | Mode::Dialog | Mode::Browser
            );
        }
        if self.mode == Mode::Command {
            for character in text.chars() {
                self.command_line.insert(character);
            }
            return true;
        }
        if self.mode == Mode::Dialog
            && let Some(workflow) = self.workflow.as_mut()
        {
            for character in text.chars() {
                workflow.handle_key(
                    crossterm::event::KeyCode::Char(character),
                    crossterm::event::KeyModifiers::NONE,
                );
            }
            return true;
        }
        if self.mode == Mode::Editor {
            for character in text.chars() {
                self.handle_editor_input(crossterm::event::KeyCode::Char(character));
            }
            return true;
        }
        if self.sidebar.is_editing_filter() {
            for character in text.chars() {
                self.sidebar.push_filter_character(character);
            }
            return true;
        }
        false
    }

    fn submit_command(&mut self) -> AppEffect {
        let invocation = match self.command_line.submit() {
            Ok(invocation) => invocation,
            Err(error) => {
                self.status = format!("Command error: {error}");
                return AppEffect::None;
            }
        };
        self.route_command(invocation)
    }

    fn route_command(&mut self, invocation: CommandInvocation) -> AppEffect {
        let context = self.command_context_mode();
        match invocation {
            CommandInvocation::Display(text) => {
                self.show_command_help(text);
                AppEffect::None
            }
            CommandInvocation::Lock if context != Mode::Locked => {
                self.transition(Transition::Dismiss);
                self.transition(Transition::Lock);
                AppEffect::ManualLock
            }
            CommandInvocation::Unlock if context == Mode::Locked => {
                self.transition(Transition::Dismiss);
                self.transition(Transition::Unlock);
                AppEffect::None
            }
            CommandInvocation::Quit if context != Mode::Editor => {
                self.transition(Transition::Dismiss);
                self.should_quit = true;
                AppEffect::None
            }
            CommandInvocation::ResolveGit(choice) => {
                let Some(view) = self.git_view.as_ref() else {
                    self.status = "There is no active Git conflict set".to_owned();
                    return AppEffect::None;
                };
                if view.conflicts.is_empty() {
                    self.status = "The active Git snapshot has no conflicts".to_owned();
                    return AppEffect::None;
                }
                let resolutions = view
                    .conflicts
                    .iter()
                    .map(|conflict| {
                        ironstorage::git::GitConflictResolution::new(
                            conflict.path().to_owned(),
                            choice,
                        )
                    })
                    .collect();
                self.git_pending = true;
                self.status = format!("Resolving all conflicts with {choice:?} versions…");
                AppEffect::ResolveGit(resolutions)
            }
            CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
                self.status = "Command is unavailable in the current mode".to_owned();
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Help { topic }) => {
                self.show_command_help(help_text(topic));
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Version) => {
                self.transition(Transition::Dismiss);
                self.status = version_text().trim().to_owned();
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Help)) => {
                self.show_command_help(help_text(Some(ironstorage::command::HelpTopic::Otp)));
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Version)) => {
                self.transition(Transition::Dismiss);
                self.status = format!("pass-otp {}", otp_version_text().trim());
                AppEffect::None
            }
            CommandInvocation::Storage(request) if context == Mode::Locked => {
                self.status = format!(
                    "{} is unavailable while the password store is locked",
                    operation_name(&request)
                );
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Code(_))) => {
                self.transition(Transition::Dismiss);
                self.hotp_confirmation = Some(OtpUiRequest {
                    request,
                    confirmed_hotp: false,
                    field: None,
                });
                self.transition(Transition::OpenDialog);
                self.status =
                    "Confirm OTP generation; HOTP advances and commits its counter".to_owned();
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Uri(_))) => {
                self.transition(Transition::Dismiss);
                self.otp_pending = true;
                AppEffect::AuthenticateOtp(OtpUiRequest {
                    request,
                    confirmed_hotp: false,
                    field: None,
                })
            }
            CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Insert(request))) => {
                self.transition(Transition::Dismiss);
                self.workflow = Some(WorkflowForm::otp(
                    OtpFormKind::Insert,
                    request.entry,
                    request.force,
                ));
                self.transition(Transition::OpenDialog);
                self.status = "OTP insert form: enter a URI, confirm, then C-s".to_owned();
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Append(request))) => {
                self.transition(Transition::Dismiss);
                self.workflow = Some(WorkflowForm::otp(
                    OtpFormKind::Append,
                    Some(request.entry),
                    request.force,
                ));
                self.transition(Transition::OpenDialog);
                self.status = "OTP append form: enter a URI, confirm, then C-s".to_owned();
                AppEffect::None
            }
            CommandInvocation::Storage(CommandRequest::Show(request))
                if request.presentation == Presentation::Terminal =>
            {
                if context == Mode::Editor {
                    self.status = "show is unavailable while an editor is open".to_owned();
                    return AppEffect::None;
                }
                self.transition(Transition::Dismiss);
                if let Some(entry) = request.entry {
                    self.begin_command_open(entry, CommandOpenTarget::Viewer)
                } else {
                    self.close_to_browser();
                    self.status = "Showing the password-store root".to_owned();
                    AppEffect::None
                }
            }
            CommandInvocation::Storage(CommandRequest::Show(request)) => {
                self.transition(Transition::Dismiss);
                self.status = "Authenticating for secret presentation…".to_owned();
                AppEffect::AuthenticateShow(request)
            }
            CommandInvocation::Storage(CommandRequest::Edit(request)) => {
                if context == Mode::Editor {
                    if self.selected_entry.as_deref() == Some(&request.entry) {
                        self.transition(Transition::Dismiss);
                        self.status = format!("Already editing {}", request.entry);
                    } else {
                        self.status = "edit is unavailable while another editor is open".to_owned();
                    }
                    return AppEffect::None;
                }
                self.transition(Transition::Dismiss);
                self.begin_command_open(request.entry, CommandOpenTarget::Editor)
            }
            CommandInvocation::Storage(CommandRequest::List(request)) => {
                if context == Mode::Editor {
                    self.status = "list is unavailable while an editor is open".to_owned();
                    return AppEffect::None;
                }
                self.transition(Transition::Dismiss);
                self.close_to_browser();
                if let Some(path) = request.path {
                    if self.sidebar.select_path(&path, true) {
                        self.status = format!("Selected directory {path}");
                    } else {
                        self.status =
                            format!("Directory is unavailable in the loaded tree: {path}");
                    }
                } else {
                    self.status = "Showing the password-store root".to_owned();
                }
                AppEffect::None
            }
            CommandInvocation::Storage(request) if context == Mode::Editor => {
                self.status = format!(
                    "{} is unavailable while an editor is open",
                    operation_name(&request)
                );
                AppEffect::None
            }
            CommandInvocation::Storage(request @ CommandRequest::Init(_))
            | CommandInvocation::Storage(request @ CommandRequest::Insert(_))
            | CommandInvocation::Storage(request @ CommandRequest::Generate(_))
            | CommandInvocation::Storage(request @ CommandRequest::Grep(_))
            | CommandInvocation::Storage(request @ CommandRequest::Remove(_))
            | CommandInvocation::Storage(request @ CommandRequest::Move(_))
            | CommandInvocation::Storage(request @ CommandRequest::Copy(_))
            | CommandInvocation::Storage(request @ CommandRequest::ImportKdbx(_)) => {
                self.transition(Transition::Dismiss);
                let workflow = match &request {
                    CommandRequest::Init(_) => WorkflowAction::Initialize,
                    CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
                    CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
                    CommandRequest::Grep(_) => WorkflowAction::Grep,
                    CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
                    CommandRequest::Move(_) => WorkflowAction::MoveEntry,
                    CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
                    CommandRequest::ImportKdbx(_) => WorkflowAction::ImportKdbx,
                    _ => unreachable!(),
                };
                self.open_workflow(workflow, Some(request));
                AppEffect::None
            }
            CommandInvocation::Storage(request) => {
                let operation = operation_name(&request);
                self.transition(Transition::Dismiss);
                self.status = format!("Starting {operation} workflow…");
                AppEffect::RunCommand(request)
            }
        }
    }

    fn show_command_help(&mut self, text: String) {
        self.transition(Transition::Dismiss);
        self.command_help = Some(text);
        self.transition(Transition::OpenHelp);
        self.status = "Command help; Esc returns".to_owned();
    }

    fn begin_command_open(&mut self, entry: String, target: CommandOpenTarget) -> AppEffect {
        if self.selected_entry.as_deref() == Some(&entry) && self.mode == Mode::Viewer {
            if target == CommandOpenTarget::Editor {
                return self.dispatch(Action::EditEntry);
            }
            self.status = format!("Already viewing {entry}");
            return AppEffect::None;
        }
        self.authentication_pending = Some(entry.clone());
        self.command_open_target = Some(target);
        self.status = format!("Authenticating to open {entry}…");
        AppEffect::AuthenticateEntry(entry)
    }

    fn close_to_browser(&mut self) {
        self.grep_view = None;
        self.focus = PaneFocus::Sidebar;
        if matches!(self.mode, Mode::Viewer | Mode::Editor) {
            self.transition(Transition::CloseEntry);
        }
    }

    fn apply_secret_presentation(&mut self, presentation: SecretPresentation) {
        match presentation {
            SecretPresentation::Clipboard(value) => self.clipboard_request = Some(value),
            SecretPresentation::Qr { title, matrix } => {
                self.uri_popup = None;
                self.qr_popup = Some(QrPopup { title, matrix });
            }
        }
    }

    fn begin_clipboard_presentation(&mut self, value: SecretBytes) -> AppEffect {
        let presentation = ClipboardPresentationId(self.next_clipboard_presentation);
        self.next_clipboard_presentation = self.next_clipboard_presentation.wrapping_add(1);
        self.clipboard_presentation = Some(ClipboardPresentation {
            id: presentation,
            deadline: None,
        });
        self.status = "Copying secret to the native clipboard…".to_owned();
        AppEffect::CopyFocused {
            presentation,
            value,
        }
    }

    fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
        let form = match (workflow, request) {
            (WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
                WorkflowForm::init(
                    &self.available_keys,
                    self.default_key.as_ref(),
                    Some(request),
                )
            }
            (WorkflowAction::Initialize, None) => {
                WorkflowForm::init(&self.available_keys, self.default_key.as_ref(), None)
            }
            (WorkflowAction::InsertEntry, Some(CommandRequest::Insert(request))) => {
                WorkflowForm::insert(Some(request))
            }
            (WorkflowAction::InsertEntry, None) => WorkflowForm::insert(None),
            (WorkflowAction::GenerateEntry, Some(CommandRequest::Generate(request))) => {
                WorkflowForm::generate(Some(request))
            }
            (WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None),
            (WorkflowAction::Grep, Some(CommandRequest::Grep(request))) => {
                WorkflowForm::grep(Some(request))
            }
            (WorkflowAction::Grep, None) => WorkflowForm::grep(None),
            (WorkflowAction::ImportKdbx, Some(CommandRequest::ImportKdbx(request))) => {
                WorkflowForm::kdbx(Some(request))
            }
            (WorkflowAction::ImportKdbx, None) => WorkflowForm::kdbx(None),
            (WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => {
                WorkflowForm::remove(Some(request), None)
            }
            (WorkflowAction::RemoveEntry, None) => {
                let selected = self
                    .selected_entry
                    .as_deref()
                    .map(|entry| (entry, false))
                    .or_else(|| {
                        self.sidebar
                            .selected()
                            .map(|selected| (selected.path(), selected.is_directory()))
                    });
                WorkflowForm::remove(None, selected)
            }
            (WorkflowAction::MoveEntry, Some(CommandRequest::Move(request))) => {
                WorkflowForm::move_entry(Some(request), None)
            }
            (WorkflowAction::MoveEntry, None) => WorkflowForm::move_entry(
                None,
                self.selected_entry
                    .as_deref()
                    .or_else(|| self.sidebar.selected().map(|selected| selected.path())),
            ),
            (WorkflowAction::CopyEntry, Some(CommandRequest::Copy(request))) => {
                WorkflowForm::copy_entry(Some(request), None)
            }
            (WorkflowAction::CopyEntry, None) => WorkflowForm::copy_entry(
                None,
                self.selected_entry
                    .as_deref()
                    .or_else(|| self.sidebar.selected().map(|selected| selected.path())),
            ),
            _ => return,
        };
        self.workflow = Some(form);
        self.workflow_pending = false;
        self.transition(Transition::OpenDialog);
        self.status = "Tab moves, Space toggles, Ctrl-S submits, Esc cancels".to_owned();
    }

    fn cancel_workflow(&mut self) {
        if self.workflow_pending {
            self.status = "Wait for the active workflow to finish".to_owned();
            return;
        }
        self.workflow = None;
        self.transition(Transition::Dismiss);
        self.status = "Workflow cancelled; password store unchanged".to_owned();
    }

    pub fn workflow_authentication_granted(&mut self) -> bool {
        self.mode == Mode::Dialog && self.workflow.is_some() && self.workflow_pending
    }

    pub fn workflow_authentication_failed(&mut self, message: String) {
        self.workflow_pending = false;
        self.status = format!("Authentication failed: {message}. Form retained.");
    }

    fn editor_context_active(&self) -> bool {
        self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
    }

    pub fn handle_editor_input(&mut self, code: crossterm::event::KeyCode) -> bool {
        use crossterm::event::KeyCode;

        if !self.editor_input_active() {
            return false;
        }
        let editor = self.editor.as_mut().expect("active editor input has state");
        let result = match code {
            KeyCode::Esc => {
                editor.end_input();
                self.status = "Field input ended".to_owned();
                return true;
            }
            KeyCode::Enter => editor.split_line(),
            KeyCode::Backspace => {
                editor.backspace();
                return true;
            }
            KeyCode::Delete => {
                editor.delete();
                return true;
            }
            KeyCode::Left => {
                editor.move_cursor_left();
                return true;
            }
            KeyCode::Right => {
                editor.move_cursor_right();
                return true;
            }
            KeyCode::Home => {
                editor.move_cursor_home();
                return true;
            }
            KeyCode::End => {
                editor.move_cursor_end();
                return true;
            }
            KeyCode::Char(character) => {
                editor.insert_character(character);
                return true;
            }
            _ => return false,
        };
        if let Err(error) = result {
            self.status = error.to_string();
        }
        true
    }

    fn report_editor_result(&mut self, result: Option<Result<(), DocumentError>>) {
        if let Some(Err(error)) = result {
            self.status = error.to_string();
        }
    }

    fn begin_editor_save(&mut self) -> AppEffect {
        if self.editor_generation_pending.is_some() {
            self.status = "Wait for password generation before saving".to_owned();
            return AppEffect::None;
        }
        let Some(config) = self.config.clone() else {
            self.status = "Cannot save before configuration is loaded".to_owned();
            return AppEffect::None;
        };
        let Some(editor) = self.editor.take() else {
            return AppEffect::None;
        };
        let editor = match editor.prepare_save() {
            Ok(editor) => editor,
            Err(failure) => {
                let (editor, error) = *failure;
                self.editor = Some(editor);
                self.status = error.to_string();
                return AppEffect::None;
            }
        };
        if !editor.is_dirty() {
            match editor.into_document() {
                Ok(document) => {
                    self.viewer = Some(EntryViewer::new(document));
                    self.mode = Mode::Viewer;
                    self.status = "No changes to save".to_owned();
                }
                Err(failure) => {
                    let (editor, error) = *failure;
                    self.editor = Some(editor);
                    self.status = error.to_string();
                }
            }
            return AppEffect::None;
        }
        let Some(entry) = self.selected_entry.clone() else {
            self.editor = Some(editor);
            self.status = "Cannot save an editor without an entry identity".to_owned();
            return AppEffect::None;
        };
        self.status = format!("Saving {entry}…");
        AppEffect::SaveDocument {
            config: Box::new(config),
            entry,
            editor: Box::new(editor),
        }
    }

    fn cancel_editor(&mut self) {
        let Some(editor) = self.editor.take() else {
            return;
        };
        let editor = match editor.prepare_save() {
            Ok(editor) => editor,
            Err(failure) => {
                let (editor, error) = *failure;
                self.editor = Some(editor);
                self.status = error.to_string();
                return;
            }
        };
        if editor.is_dirty() {
            self.editor = Some(editor);
            self.discard_confirmation = true;
            self.transition(Transition::OpenDialog);
            self.status = "Discard unsaved edits? y discards; n or Esc keeps editing".to_owned();
            return;
        }
        match editor.into_document() {
            Ok(document) => {
                self.viewer = Some(EntryViewer::new(document));
                self.mode = Mode::Viewer;
                self.status = "Edit cancelled".to_owned();
            }
            Err(failure) => {
                let (editor, error) = *failure;
                self.editor = Some(editor);
                self.status = error.to_string();
            }
        }
    }

    fn discard_editor(&mut self) {
        if self.mode != Mode::Dialog || !self.discard_confirmation {
            return;
        }
        self.editor = None;
        self.viewer = None;
        self.selected_entry = None;
        self.discard_confirmation = false;
        self.suspended_mode = None;
        self.editor_generation_pending = None;
        self.mode = Mode::Browser;
        self.focus = PaneFocus::Sidebar;
        self.status = "Unsaved edits discarded".to_owned();
    }

    fn keep_editing(&mut self) {
        if self.mode != Mode::Dialog || !self.discard_confirmation {
            return;
        }
        self.discard_confirmation = false;
        self.transition(Transition::Dismiss);
        self.status = "Continuing edit".to_owned();
    }

    pub fn authentication_granted(&mut self, entry: String) -> bool {
        if self.authentication_pending.as_deref() != Some(&entry) {
            return false;
        }
        self.authentication_pending = None;
        self.selected_entry = Some(entry);
        self.viewer = None;
        self.editor = None;
        self.grep_view = None;
        self.status = "Authenticated; loading structured entry…".to_owned();
        let transitioned = self.transition(Transition::OpenEntry);
        if transitioned {
            self.focus = PaneFocus::Main;
        }
        transitioned
    }

    pub fn authentication_failed(&mut self, message: String) {
        self.git_pending = false;
        self.otp_pending = false;
        self.otp_display = None;
        self.qr_popup = None;
        self.uri_popup = None;
        self.clipboard_request = None;
        self.clipboard_presentation = None;
        self.authentication_pending = None;
        self.selected_entry = None;
        self.viewer = None;
        self.editor = None;
        self.discard_confirmation = false;
        self.editor_generation_pending = None;
        self.command_open_target = None;
        self.status = message;
        if self.mode != Mode::Browser {
            self.mode = Mode::Browser;
            self.focus = PaneFocus::Sidebar;
        }
    }

    pub fn report_status(&mut self, message: String) {
        self.status = message;
    }

    pub fn update_remaining_lease(&mut self, remaining: Option<std::time::Duration>) {
        self.remaining_lease = remaining;
    }

    pub fn forced_relock(&mut self, reason: &'static str) {
        let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
        self.transition(Transition::Lock);
        self.authentication_pending = None;
        self.git_pending = false;
        self.git_view = None;
        self.otp_pending = false;
        self.otp_display = None;
        self.qr_popup = None;
        self.uri_popup = None;
        self.clipboard_request = None;
        self.clipboard_presentation = None;
        self.remaining_lease = None;
        self.status = if discarded_edit {
            format!("Locked: {reason}; unsaved edits were discarded")
        } else {
            format!("Locked: {reason}")
        };
    }

    pub fn transition(&mut self, transition: Transition) -> bool {
        let current = self.mode;
        let destination = match (current, transition) {
            (Mode::Browser, Transition::OpenEntry) => Some(Mode::Viewer),
            (Mode::Viewer, Transition::EditEntry) => Some(Mode::Editor),
            (Mode::Viewer | Mode::Editor, Transition::CloseEntry) => Some(Mode::Browser),
            (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => {
                Some(Mode::Dialog)
            }
            (Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, Transition::OpenHelp) => {
                Some(Mode::Help)
            }
            (
                Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked,
                Transition::OpenCommand,
            ) => Some(Mode::Command),
            (Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
                self.suspended_mode.take()
            }
            (
                Mode::Browser
                | Mode::Viewer
                | Mode::Editor
                | Mode::Dialog
                | Mode::Help
                | Mode::Command,
                Transition::Lock,
            ) => Some(Mode::Locked),
            (Mode::Locked, Transition::Unlock) => Some(Mode::Browser),
            _ => None,
        };

        let Some(destination) = destination else {
            return false;
        };
        if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
            if let Some(editor) = self.editor.as_mut() {
                editor.end_input();
            }
            self.suspended_mode = Some(current);
        } else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
            self.selected_entry = None;
            self.viewer = None;
            self.editor = None;
            self.authentication_pending = None;
            self.remaining_lease = None;
            self.discard_confirmation = false;
            self.editor_generation_pending = None;
            self.focus = PaneFocus::Sidebar;
        } else if destination == Mode::Locked {
            self.suspended_mode = None;
            self.focus = PaneFocus::Sidebar;
            self.invalidate_requests();
            self.selected_entry = None;
            self.viewer = None;
            self.editor = None;
            self.discard_confirmation = false;
            self.command_line.clear();
            self.command_help = None;
            self.command_open_target = None;
            self.editor_generation_pending = None;
            self.workflow = None;
            self.workflow_pending = false;
            self.grep_view = None;
            self.otp_pending = false;
            self.otp_display = None;
            self.hotp_confirmation = None;
            self.qr_popup = None;
            self.uri_popup = None;
            self.clipboard_request = None;
            self.clipboard_presentation = None;
            self.status = "Locked".to_owned();
        } else if current == Mode::Locked {
            self.status = "Authentication required".to_owned();
        }
        self.mode = destination;
        true
    }

    fn invalidate_requests(&mut self) {
        self.generation = self.generation.wrapping_add(1);
        self.pending.clear();
    }

    #[cfg(test)]
    pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) {
        self.mode = Mode::Viewer;
        self.focus = PaneFocus::Main;
        self.selected_entry = Some(entry.to_owned());
        self.viewer = Some(EntryViewer::new(document));
        self.status = format!("Opened {entry}");
    }
}

fn git_phase_name(phase: GitProgressPhase) -> &'static str {
    match phase {
        GitProgressPhase::Validating => "validating repository",
        GitProgressPhase::Authenticating => "requesting credentials",
        GitProgressPhase::Receiving => "receiving remote objects",
        GitProgressPhase::Integrating => "integrating fetched changes",
        GitProgressPhase::Sending => "sending local objects",
        GitProgressPhase::Refreshing => "refreshing synchronized snapshot",
    }
}

fn workflow_label(workflow: WorkflowAction) -> &'static str {
    match workflow {
        WorkflowAction::Initialize => "initialization",
        WorkflowAction::InsertEntry => "entry insertion",
        WorkflowAction::GenerateEntry => "entry generation",
        WorkflowAction::Grep => "decrypted grep",
        WorkflowAction::ImportKdbx => "KDBX import",
        WorkflowAction::RemoveEntry => "entry removal",
        WorkflowAction::MoveEntry => "entry move",
        WorkflowAction::CopyEntry => "entry copy",
        WorkflowAction::GitPull => "Git pull",
        WorkflowAction::GitPush => "Git push",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::viewer::test_support::fixture_document;

    fn enter_command(app: &mut App, command: &str) -> AppEffect {
        assert!(matches!(app.dispatch(Action::Command), AppEffect::None));
        for character in command.chars() {
            assert!(matches!(
                app.handle_command_input(
                    crossterm::event::KeyCode::Char(character),
                    crossterm::event::KeyModifiers::NONE,
                ),
                Some(AppEffect::None)
            ));
        }
        app.handle_command_input(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::NONE,
        )
        .expect("command mode consumes Enter")
    }

    #[test]
    fn every_legal_transition_reaches_its_destination() {
        let cases = [
            (Mode::Browser, Transition::OpenEntry, Mode::Viewer),
            (Mode::Viewer, Transition::EditEntry, Mode::Editor),
            (Mode::Viewer, Transition::CloseEntry, Mode::Browser),
            (Mode::Editor, Transition::CloseEntry, Mode::Browser),
            (Mode::Locked, Transition::Unlock, Mode::Browser),
        ];
        for (start, transition, expected) in cases {
            let mut app = App::new();
            app.mode = start;
            assert!(app.transition(transition));
            assert_eq!(app.mode(), expected);
        }

        for start in [Mode::Browser, Mode::Viewer, Mode::Editor] {
            for (transition, overlay) in [
                (Transition::OpenDialog, Mode::Dialog),
                (Transition::OpenHelp, Mode::Help),
                (Transition::OpenCommand, Mode::Command),
            ] {
                let mut app = App::new();
                app.mode = start;
                assert!(app.transition(transition));
                assert_eq!(app.mode(), overlay);
                assert!(app.transition(Transition::Dismiss));
                assert_eq!(app.mode(), start);
            }
            let mut app = App::new();
            app.mode = start;
            assert!(app.transition(Transition::Lock));
            assert_eq!(app.mode(), Mode::Locked);
        }
    }

    #[test]
    fn illegal_transitions_do_not_change_state() {
        let mut app = App::new();
        assert!(!app.transition(Transition::EditEntry));
        assert_eq!(app.mode(), Mode::Browser);
        assert!(!app.transition(Transition::Dismiss));
        assert_eq!(app.mode(), Mode::Browser);
    }

    #[test]
    fn stale_and_duplicate_async_results_are_rejected() {
        let mut app = App::new();
        let token = app.begin_request();
        let result = AsyncResult {
            token,
            payload: Err("completed".to_owned()),
        };
        assert_eq!(app.apply_result(result), ResultDisposition::Applied);
        assert_eq!(
            app.apply_result(AsyncResult {
                token,
                payload: Err("duplicate".to_owned()),
            }),
            ResultDisposition::Stale
        );

        let stale = app.begin_request();
        assert!(app.transition(Transition::Lock));
        assert_eq!(
            app.apply_result(AsyncResult {
                token: stale,
                payload: Err("stale".to_owned()),
            }),
            ResultDisposition::Stale
        );
        assert_eq!(app.status(), "Locked");
    }

    #[test]
    fn storage_results_are_applied_without_domain_inference() {
        let mut app = App::new();
        let token = app.begin_request();
        assert_eq!(
            app.apply_result(AsyncResult {
                token,
                payload: Err("typed storage failure".to_owned()),
            }),
            ResultDisposition::Applied
        );
        assert_eq!(app.status(), "typed storage failure");
    }

    #[test]
    fn ticks_resize_and_focus_are_independent_ui_state() {
        let mut app = App::new();
        app.resize(100, 30);
        app.tick();
        app.dispatch(Action::FocusNext);
        assert_eq!(app.terminal_size(), (100, 30));
        assert_eq!(app.ticks(), 1);
        assert_eq!(app.focus(), PaneFocus::Main);
    }

    #[test]
    fn clipboard_countdown_resets_finishes_and_cannot_survive_lock() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        let first = match app.dispatch(Action::Copy) {
            AppEffect::CopyFocused { presentation, .. } => presentation,
            effect => panic!("unexpected effect: {effect:?}"),
        };
        let first_token = app.begin_request();
        let first_deadline = Instant::now() + std::time::Duration::from_secs(3);
        assert_eq!(
            app.apply_result(AsyncResult {
                token: first_token,
                payload: Ok(AsyncPayload::ClipboardStarted {
                    presentation: first,
                    deadline: first_deadline,
                }),
            }),
            ResultDisposition::Applied
        );
        assert_eq!(
            app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(3)),
            Some(3)
        );
        assert_eq!(
            app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(1)),
            Some(1)
        );
        assert_eq!(app.clipboard_remaining_seconds_at(first_deadline), Some(0));

        let second = match app.dispatch(Action::Copy) {
            AppEffect::CopyFocused { presentation, .. } => presentation,
            effect => panic!("unexpected effect: {effect:?}"),
        };
        assert_ne!(first, second);
        assert_eq!(
            app.apply_result(AsyncResult {
                token: first_token,
                payload: Ok(AsyncPayload::ClipboardFinished {
                    presentation: first,
                    result: Err(ClipboardError::Cancelled),
                }),
            }),
            ResultDisposition::Applied
        );
        assert!(app.clipboard_pending());
        assert_eq!(app.clipboard_remaining_seconds(), None);

        let second_token = app.begin_request();
        let second_deadline = Instant::now() + std::time::Duration::from_secs(5);
        app.apply_result(AsyncResult {
            token: second_token,
            payload: Ok(AsyncPayload::ClipboardStarted {
                presentation: second,
                deadline: second_deadline,
            }),
        });
        assert_eq!(
            app.clipboard_remaining_seconds_at(second_deadline - std::time::Duration::from_secs(5)),
            Some(5)
        );
        app.apply_result(AsyncResult {
            token: second_token,
            payload: Ok(AsyncPayload::ClipboardFinished {
                presentation: second,
                result: Ok(ClipboardDisposition::Cleared),
            }),
        });
        assert!(!app.clipboard_pending());
        assert_eq!(app.clipboard_remaining_seconds(), None);
        assert_eq!(app.status(), "Clipboard secret cleared");

        let third = match app.dispatch(Action::Copy) {
            AppEffect::CopyFocused { presentation, .. } => presentation,
            effect => panic!("unexpected effect: {effect:?}"),
        };
        let third_token = app.begin_request();
        app.apply_result(AsyncResult {
            token: third_token,
            payload: Ok(AsyncPayload::ClipboardStarted {
                presentation: third,
                deadline: Instant::now() + std::time::Duration::from_secs(5),
            }),
        });
        assert!(matches!(app.dispatch(Action::Lock), AppEffect::ManualLock));
        assert!(!app.clipboard_pending());
        assert_eq!(
            app.apply_result(AsyncResult {
                token: third_token,
                payload: Ok(AsyncPayload::ClipboardFinished {
                    presentation: third,
                    result: Ok(ClipboardDisposition::Cleared),
                }),
            }),
            ResultDisposition::Stale
        );
    }

    #[test]
    fn authentication_must_match_the_pending_entry_before_viewer_transition() {
        let mut app = App::new();
        app.authentication_pending = Some("expected".to_owned());
        assert!(!app.authentication_granted("stale".to_owned()));
        assert_eq!(app.mode(), Mode::Browser);
        assert!(app.authentication_granted("expected".to_owned()));
        assert_eq!(app.mode(), Mode::Viewer);
        assert_eq!(app.selected_entry(), Some("expected"));
    }

    #[test]
    fn denial_expiry_and_forced_editor_relock_remove_entry_state() {
        let mut app = App::new();
        app.authentication_pending = Some("secret".to_owned());
        app.authentication_failed("authentication was denied".to_owned());
        assert_eq!(app.mode(), Mode::Browser);
        assert_eq!(app.selected_entry(), None);
        assert!(!app.authentication_pending());

        app.mode = Mode::Editor;
        app.selected_entry = Some("secret".to_owned());
        app.remaining_lease = Some(std::time::Duration::from_secs(1));
        app.forced_relock("authentication lease expired");
        assert_eq!(app.mode(), Mode::Locked);
        assert_eq!(app.focus(), PaneFocus::Sidebar);
        assert_eq!(app.selected_entry(), None);
        assert_eq!(app.remaining_lease(), None);
        assert!(app.status().contains("unsaved edits were discarded"));
    }

    #[test]
    fn conflict_and_storage_save_failures_restore_the_complete_editor_draft() {
        for (kind, message) in [
            (EditorSaveFailureKind::Conflict, "concurrent ciphertext"),
            (EditorSaveFailureKind::Storage, "encryption unavailable"),
        ] {
            let mut app = App::new();
            app.open_test_document("email/personal", fixture_document("email/personal"));
            app.dispatch(Action::EditEntry);
            app.editor
                .as_mut()
                .expect("editor")
                .add_after_focused()
                .expect("dirty document");
            let editor = Box::new(app.editor.take().expect("move editor to worker"));
            let token = app.begin_request();
            assert_eq!(
                app.apply_result(AsyncResult {
                    token,
                    payload: Ok(AsyncPayload::DocumentSaveFinished {
                        entry: "email/personal".to_owned(),
                        editor,
                        result: Err(EditorSaveFailure::new(kind, message.to_owned())),
                    }),
                }),
                ResultDisposition::Applied
            );
            assert_eq!(app.mode(), Mode::Editor);
            assert!(app.editor().is_some_and(EntryEditor::is_dirty));
            assert!(app.status().contains(message));
        }
    }

    #[test]
    fn editor_results_complete_safely_while_help_temporarily_has_focus() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(Action::EditEntry);
        let target = app
            .editor
            .as_mut()
            .expect("editor")
            .generation_target()
            .expect("target")
            .expect("password target");
        app.editor_generation_pending = Some(target);
        let token = app.begin_request();
        app.dispatch(Action::Help);
        assert_eq!(app.mode(), Mode::Help);
        assert_eq!(
            app.apply_result(AsyncResult {
                token,
                payload: Ok(AsyncPayload::GeneratedField {
                    target,
                    password: SecretBytes::new(b"generated-under-help".to_vec()),
                }),
            }),
            ResultDisposition::Applied
        );
        app.dispatch(Action::Cancel);
        assert_eq!(app.mode(), Mode::Editor);
        assert_eq!(
            app.editor()
                .and_then(EntryEditor::focused_field)
                .expect("password")
                .value(),
            b"generated-under-help"
        );
    }

    #[test]
    fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() {
        let mut app = App::new();
        assert!(matches!(app.dispatch(Action::InsertEntry), AppEffect::None));
        assert_eq!(app.mode(), Mode::Dialog);
        assert!(app.workflow().is_some());
        app.dispatch(Action::Cancel);
        app.open_test_document("email/personal", fixture_document("email/personal"));
        assert!(matches!(app.dispatch(Action::RemoveEntry), AppEffect::None));
        assert_eq!(app.mode(), Mode::Dialog);
        let rows = app.workflow().expect("remove form").rows().join("\n");
        assert!(rows.contains("email/personal"));
        assert!(rows.contains("Confirm permanent removal: no"));
    }

    #[test]
    fn colon_write_commands_open_the_same_secure_forms_with_typed_options() {
        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "insert --multiline --force nested/note"),
            AppEffect::None
        ));
        assert_eq!(app.mode(), Mode::Dialog);
        let rows = app.workflow().expect("insert form").rows().join("\n");
        assert!(rows.contains("nested/note"));
        assert!(rows.contains("multiline"));
        assert!(rows.contains("Overwrite: yes"));
        app.handle_workflow_input(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::NONE,
        );
        assert_eq!(app.mode(), Mode::Browser);

        assert!(matches!(
            enter_command(&mut app, "generate --no-symbols --in-place nested/note 31"),
            AppEffect::None
        ));
        let rows = app.workflow().expect("generate form").rows().join("\n");
        assert!(rows.contains("Length: 31"));
        assert!(rows.contains("Symbols: no"));
        assert!(rows.contains("In place: yes"));
    }

    #[test]
    fn colon_show_and_edit_route_to_authenticated_tui_panes() {
        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "show 'email/personal account'"),
            AppEffect::AuthenticateEntry(entry) if entry == "email/personal account"
        ));
        assert_eq!(app.mode(), Mode::Browser);

        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "show --clip=2 email/personal"),
            AppEffect::AuthenticateShow(ironstorage::command::ShowRequest {
                entry: Some(entry),
                presentation: Presentation::Clipboard { .. },
            }) if entry == "email/personal"
        ));

        app.open_test_document("email/personal", fixture_document("email/personal"));
        assert!(matches!(
            enter_command(&mut app, "edit email/personal"),
            AppEffect::None
        ));
        assert_eq!(app.mode(), Mode::Editor);
    }

    #[test]
    fn destructive_colon_commands_require_confirmation_and_can_be_cancelled() {
        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "remove -r old/folder"),
            AppEffect::None
        ));
        assert_eq!(app.mode(), Mode::Dialog);
        let rows = app.workflow().expect("remove form").rows().join("\n");
        assert!(rows.contains("old/folder"));
        assert!(rows.contains("Recursive folder: yes"));
        assert!(rows.contains("Confirm permanent removal: no"));
        assert!(matches!(
            app.handle_workflow_input(
                crossterm::event::KeyCode::Char('s'),
                crossterm::event::KeyModifiers::CONTROL,
            ),
            Some(AppEffect::None)
        ));
        assert!(app.status().contains("Explicitly confirm"));
        for _ in 0..3 {
            app.handle_workflow_input(
                crossterm::event::KeyCode::Tab,
                crossterm::event::KeyModifiers::NONE,
            );
        }
        app.handle_workflow_input(
            crossterm::event::KeyCode::Char(' '),
            crossterm::event::KeyModifiers::NONE,
        );
        assert!(matches!(
            app.handle_workflow_input(
                crossterm::event::KeyCode::Char('s'),
                crossterm::event::KeyModifiers::CONTROL,
            ),
            Some(AppEffect::AuthenticateWorkflow(submission))
                if matches!(*submission, WorkflowSubmission::Remove(ironstorage::command::RemoveRequest {
                    ref entry,
                    recursive: true,
                    ..
                }) if entry == "old/folder")
        ));

        app.workflow_authentication_failed("cancel test".to_owned());
        app.handle_workflow_input(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::NONE,
        );
        assert_eq!(app.mode(), Mode::Browser);

        assert!(matches!(
            enter_command(&mut app, "remove old/other"),
            AppEffect::None
        ));
        app.handle_workflow_input(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::NONE,
        );
        assert_eq!(app.mode(), Mode::Browser);
        assert!(app.status().contains("cancelled"));
    }

    #[test]
    fn mutation_failures_retain_all_explicit_choices_for_retry() {
        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "copy --force source/entry destination/folder/"),
            AppEffect::None
        ));
        let before = app.workflow().expect("copy form").rows();
        assert!(
            before
                .iter()
                .any(|row| row.contains("Overwrite collision: yes"))
        );
        assert!(
            before
                .iter()
                .any(|row| row.contains("Destination is existing directory: yes"))
        );
        assert!(matches!(
            app.handle_workflow_input(
                crossterm::event::KeyCode::Char('s'),
                crossterm::event::KeyModifiers::CONTROL,
            ),
            Some(AppEffect::AuthenticateWorkflow(_))
        ));
        let token = app.begin_request();
        assert_eq!(
            app.apply_result(AsyncResult {
                token,
                payload: Ok(AsyncPayload::WorkflowFinished(Err(
                    "typed destination collision".to_owned(),
                ))),
            }),
            ResultDisposition::Applied
        );
        assert_eq!(app.mode(), Mode::Dialog);
        assert_eq!(app.workflow().expect("retained form").rows(), before);
        assert!(app.status().contains("typed destination collision"));
        assert!(app.status().contains("Form retained"));
    }

    #[test]
    fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() {
        let mut app = App::new();
        app.open_test_document("email/personal", fixture_document("email/personal"));
        app.dispatch(Action::EditEntry);
        assert_eq!(app.mode(), Mode::Editor);
        assert!(matches!(
            enter_command(&mut app, "show another/entry"),
            AppEffect::None
        ));
        assert_eq!(app.mode(), Mode::Command);
        assert!(app.status().contains("unavailable while an editor is open"));
        app.dispatch(Action::Cancel);
        assert_eq!(app.mode(), Mode::Editor);

        app.forced_relock("test");
        assert_eq!(app.mode(), Mode::Locked);
        assert!(matches!(enter_command(&mut app, "unlock"), AppEffect::None));
        assert_eq!(app.mode(), Mode::Browser);
    }

    #[test]
    fn secret_bearing_commands_never_enter_history_or_status() {
        let mut app = App::new();
        let secret = "NEVER-RENDER-THIS";
        assert!(matches!(
            enter_command(
                &mut app,
                &format!("otp validate otpauth://totp/test?secret={secret}"),
            ),
            AppEffect::RunCommand(CommandRequest::Otp(OtpRequest::Validate { .. }))
        ));
        assert!(app.command_line().history().is_empty());
        assert!(!app.status().contains(secret));
    }

    #[test]
    fn bracketed_paste_is_inert_unicode_text_and_rejects_submission_controls() {
        let mut app = App::new();
        app.dispatch(Action::Command);
        assert!(app.handle_paste("show unicode/咖啡"));
        assert_eq!(app.command_line().input(), "show unicode/咖啡");
        assert!(app.handle_paste("\nremove everything"));
        assert_eq!(app.command_line().input(), "show unicode/咖啡");
        assert!(app.status().contains("control characters"));

        app.dispatch(Action::Cancel);
        app.dispatch(Action::Filter);
        assert!(app.handle_paste("咖啡"));
        assert_eq!(app.sidebar().filter_query(), "咖啡");

        let mut app = App::new();
        app.dispatch(Action::InsertEntry);
        app.handle_workflow_input(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::NONE,
        );
        app.handle_workflow_input(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::NONE,
        );
        assert!(app.handle_paste("pasted-secret"));
        let rows = app.workflow().expect("insert form").rows().join("\n");
        assert!(rows.contains("••••••••"));
        assert!(!rows.contains("pasted-secret"));
    }

    #[test]
    fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
        let mut app = App::new();
        assert!(matches!(
            enter_command(&mut app, "otp code otp/hotp"),
            AppEffect::None
        ));
        assert_eq!(app.mode(), Mode::Dialog);
        assert!(app.hotp_confirmation());
        assert!(matches!(
            app.dispatch(Action::ConfirmDiscard),
            AppEffect::AuthenticateOtp(OtpUiRequest {
                confirmed_hotp: true,
                ..
            })
        ));

        app.workflow_pending = false;
        app.mode = Mode::Browser;
        app.dispatch(Action::OtpInsert);
        let secret = "otpauth://totp/test?secret=NEVER-RENDER";
        app.workflow.as_mut().expect("OTP form").handle_key(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::NONE,
        );
        for character in secret.chars() {
            app.workflow.as_mut().expect("OTP form").handle_key(
                crossterm::event::KeyCode::Char(character),
                crossterm::event::KeyModifiers::NONE,
            );
        }
        let rows = app.workflow().expect("OTP form").rows().join("\n");
        assert!(rows.contains("••••••••"));
        assert!(!rows.contains("NEVER-RENDER"));
    }
}
