//! Pure `update(model, msg) -> Vec<Cmd>`.
//!
//! The only function in the crate allowed to mutate the model. No I/O,
//! no terminal access, no thread spawning, everything that touches the
//! outside world is returned as a [`Cmd`] for the runtime loop to
//! interpret.

use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use rite_model::{Prompt, StepFact};
use rite_runtime::{
    ExecEvent, PromptId, Response, UiCommand, UiSignal, fact_summary, signal_summary,
};

use crate::model::{
    DEVIATION_INPUT_MAX, DeviationView, LogLine, Model, PendingPrompt, RunningState, Screen,
    StepTab, StepView,
};
use crate::msg::{Cmd, Msg};

/// Number of ticks (each [`super::runtime::TICK_INTERVAL`] long,
/// 100ms today) between successive pops from the drip queue. `1`
/// gives ~100ms between log lines, which reads as a brisk type-out
/// for a burst of facts. Set to `0` to apply events immediately and
/// disable the effect.
const LOG_DRIP_TICKS: u64 = 1;

/// Apply a [`Msg`] to the model and return any side effects to perform.
pub fn update(model: &mut Model, msg: Msg) -> Vec<Cmd> {
    match msg {
        Msg::Tick => {
            model.tick = model.tick.wrapping_add(1);
            model.now = chrono::Local::now();
            if drip_due(model)
                && let Some(event) = model.pending_events.pop_front()
            {
                return handle_exec_event(model, event);
            }
            Vec::new()
        }
        Msg::Key(key) => handle_key(model, key),
        // Don't apply executor events directly: queue them so a burst of
        // facts from a single step types out one log line at a time.
        // `Msg::Tick` drains the queue at `LOG_DRIP_TICKS` cadence.
        Msg::Exec(event) => {
            model.pending_events.push_back(event);
            Vec::new()
        }
        // The forwarder sends Msg::Quit when the executor's channel
        // closes, which happens right after the terminal fact. The
        // terminal fact (CeremonyCompleted / CeremonyFailed) and the
        // Finalized fingerprint usually arrive still queued in the drip
        // buffer, so flush it now: otherwise the screen isn't terminal
        // yet and we'd tear the TUI down before the operator ever sees
        // the completion screen (and the fingerprint to write down).
        // Once flushed, only stay if we actually reached a terminal
        // screen; require an explicit key to leave it.
        Msg::Quit => {
            while let Some(event) = model.pending_events.pop_front() {
                handle_exec_event(model, event);
            }
            if model.screen.is_terminal() {
                Vec::new()
            } else {
                vec![Cmd::Quit]
            }
        }
        Msg::Resize { .. } | Msg::Mouse(_) => Vec::new(),
    }
}

/// Whether the current tick should drain one event from the drip queue.
fn drip_due(model: &Model) -> bool {
    if model.pending_events.is_empty() {
        return false;
    }
    LOG_DRIP_TICKS == 0 || model.tick.is_multiple_of(LOG_DRIP_TICKS)
}

fn handle_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
    // Ctrl+C is the hard-quit escape hatch and is intentionally not
    // advertised in the footer: Esc is the documented abort path.
    // Closes the channel; the executor sees the disconnect and unwinds.
    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
        return vec![Cmd::Quit];
    }

    // Per-screen routing. The order matters: terminal screens take
    // precedence over modals, modals over the regular step screen.
    if model.screen.is_terminal() {
        return handle_terminal_key(key);
    }
    match &model.screen {
        Screen::DeviationModal { .. } => return handle_deviation_modal_key(model, key),
        Screen::AbortConfirm => return handle_abort_confirm_key(model, key),
        _ => {}
    }

    // Tab and scroll keys take precedence over the pending prompt: the
    // operator must be able to look back at history mid-prompt without
    // first answering it. Tabs share the same `log_scroll` so the view
    // position carries across when switching with Tab.
    if matches!(key.code, KeyCode::Tab)
        && let Screen::Step { tab } = &mut model.screen
    {
        *tab = match *tab {
            StepTab::Overview => StepTab::Ceremony,
            StepTab::Ceremony => StepTab::Deviations,
            StepTab::Deviations => StepTab::System,
            StepTab::System => StepTab::Overview,
        };
        return Vec::new();
    }
    if matches!(
        &model.screen,
        Screen::Step {
            tab: StepTab::Ceremony
        }
    ) && handle_log_scroll(model, key.code)
    {
        return Vec::new();
    }

    // Prompt routing only fires on the Ceremony tab. The pending prompt
    // is rendered there; the Overview tab deliberately omits it so the
    // operator has to switch tabs to answer (a deliberate "are you ready
    // to move on" act). The Deviations tab pauses the prompt the same way.
    if model.pending_prompt.is_some()
        && matches!(
            &model.screen,
            Screen::Step {
                tab: StepTab::Ceremony
            }
        )
    {
        return handle_prompt_key(model, key);
    }

    // Default key bindings on the step screen.
    match key.code {
        KeyCode::Esc => {
            model.open_modal(Screen::AbortConfirm);
            Vec::new()
        }
        KeyCode::Char('d')
            if matches!(
                &model.screen,
                Screen::Step {
                    tab: StepTab::Deviations
                }
            ) =>
        {
            model.open_modal(Screen::DeviationModal {
                input: String::new(),
            });
            Vec::new()
        }
        _ => Vec::new(),
    }
}

/// Coarse page step for `PgUp` / `PgDn` on the Log tab. The view's actual
/// height isn't visible from `update`; ten lines is a usable default for
/// the terminal sizes we care about.
const LOG_PAGE_STEP: usize = 10;

/// Handle Log-tab scroll keys. Returns `true` if the key was consumed.
fn handle_log_scroll(model: &mut Model, code: KeyCode) -> bool {
    match code {
        KeyCode::Up => {
            let max = model.log.len();
            model.log_scroll = model.log_scroll.saturating_add(1).min(max);
            true
        }
        KeyCode::Down => {
            model.log_scroll = model.log_scroll.saturating_sub(1);
            true
        }
        KeyCode::PageUp => {
            let max = model.log.len();
            model.log_scroll = model.log_scroll.saturating_add(LOG_PAGE_STEP).min(max);
            true
        }
        KeyCode::PageDown => {
            model.log_scroll = model.log_scroll.saturating_sub(LOG_PAGE_STEP);
            true
        }
        KeyCode::Home => {
            model.log_scroll = model.log.len();
            true
        }
        KeyCode::End => {
            model.log_scroll = 0;
            true
        }
        _ => false,
    }
}

fn handle_terminal_key(key: KeyEvent) -> Vec<Cmd> {
    match key.code {
        KeyCode::Enter | KeyCode::Esc => vec![Cmd::Quit],
        _ => Vec::new(),
    }
}

fn handle_deviation_modal_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
    // Borrow input through the screen variant; only this screen has it.
    let Screen::DeviationModal { input } = &mut model.screen else {
        return Vec::new();
    };
    match key.code {
        KeyCode::Esc => {
            model.close_modal();
            Vec::new()
        }
        KeyCode::Char(c) => {
            if input.len() < DEVIATION_INPUT_MAX {
                input.push(c);
            }
            Vec::new()
        }
        KeyCode::Backspace => {
            input.pop();
            Vec::new()
        }
        KeyCode::Enter => {
            if input.trim().is_empty() {
                return Vec::new();
            }
            let text = std::mem::take(input);
            model.close_modal();
            // No optimistic local push: the runtime echoes a
            // `DeviationRecorded` fact (handled in `handle_fact`) and
            // that single source keeps the list in sync with the
            // transcript without producing a duplicate.
            vec![Cmd::SendCommand(UiCommand::LogDeviation { text })]
        }
        _ => Vec::new(),
    }
}

fn handle_abort_confirm_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
    match key.code {
        KeyCode::Char('y' | 'Y') => {
            model.running = RunningState::Aborting;
            model.close_modal();
            vec![Cmd::SendCommand(UiCommand::Abort)]
        }
        KeyCode::Char('n' | 'N') | KeyCode::Esc => {
            model.close_modal();
            Vec::new()
        }
        _ => Vec::new(),
    }
}

/// Decision computed from a single key event when a prompt is pending.
///
/// Two-step shape (decide → apply) avoids holding a mutable borrow on
/// `pending_prompt` across calls that need mutable access to the model.
enum PromptAction {
    SubmitBool(bool),
    SubmitText,
    SubmitSecret,
    SubmitAcknowledge,
    PushChar(char),
    Backspace,
    Ignore,
}

fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
    use PromptAction as Action;

    // Esc during a pending prompt is the universal "I want out" escape
    // hatch, open the abort-confirm modal. The pending prompt stays
    // installed; the operator can press `n` to back out and resume.
    if matches!(key.code, KeyCode::Esc) {
        model.open_modal(Screen::AbortConfirm);
        return Vec::new();
    }

    let action = {
        let Some(pending) = model.pending_prompt.as_ref() else {
            return Vec::new();
        };
        match &pending.prompt {
            Prompt::Confirm { default, .. } => match key.code {
                KeyCode::Char('y' | 'Y') => Action::SubmitBool(true),
                KeyCode::Char('n' | 'N') => Action::SubmitBool(false),
                KeyCode::Enter => Action::SubmitBool(default.unwrap_or(true)),
                _ => Action::Ignore,
            },
            Prompt::Continue { .. } => match key.code {
                KeyCode::Enter | KeyCode::Char(' ') => Action::SubmitAcknowledge,
                _ => Action::Ignore,
            },
            Prompt::Text { .. } | Prompt::Literal { .. } => match key.code {
                KeyCode::Char(c) => Action::PushChar(c),
                KeyCode::Backspace => Action::Backspace,
                KeyCode::Enter => Action::SubmitText,
                _ => Action::Ignore,
            },
            Prompt::Secret { .. } => match key.code {
                KeyCode::Char(c) => Action::PushChar(c),
                KeyCode::Backspace => Action::Backspace,
                KeyCode::Enter => Action::SubmitSecret,
                _ => Action::Ignore,
            },
            _ => Action::Ignore,
        }
    };

    match action {
        Action::SubmitBool(b) => send_response(model, Response::Bool(b)),
        Action::SubmitAcknowledge => send_response(model, Response::Acknowledge),
        Action::SubmitText => {
            let value = model
                .pending_prompt
                .as_mut()
                .map(|p| std::mem::take(&mut p.input))
                .unwrap_or_default();
            send_response(model, Response::Text(value))
        }
        Action::SubmitSecret => {
            let value = model
                .pending_prompt
                .as_mut()
                .map(|p| std::mem::take(&mut p.input))
                .unwrap_or_default();
            send_response(model, Response::Secret(secrecy::SecretString::from(value)))
        }
        Action::PushChar(c) => {
            if let Some(pending) = model.pending_prompt.as_mut() {
                pending.input.push(c);
            }
            Vec::new()
        }
        Action::Backspace => {
            if let Some(pending) = model.pending_prompt.as_mut() {
                pending.input.pop();
            }
            Vec::new()
        }
        Action::Ignore => Vec::new(),
    }
}

fn send_response(model: &mut Model, response: Response) -> Vec<Cmd> {
    let Some(pending) = model.pending_prompt.take() else {
        return Vec::new();
    };
    vec![Cmd::SendCommand(UiCommand::PromptResponse {
        prompt_id: pending.prompt_id,
        response,
    })]
}

fn handle_exec_event(model: &mut Model, event: ExecEvent) -> Vec<Cmd> {
    match event {
        ExecEvent::Fact { at, fact } => handle_fact(model, at, &fact),
        ExecEvent::Signal(signal) => handle_signal(model, &signal),
        ExecEvent::AwaitPrompt {
            prompt_id,
            prompt,
            previous_attempt_rejected_because,
            ..
        } => {
            install_prompt(model, prompt_id, prompt, previous_attempt_rejected_because);
            Vec::new()
        }
        ExecEvent::Finalized { fingerprint } => {
            if let Screen::Completed {
                fingerprint: fp, ..
            } = &mut model.screen
            {
                *fp = Some(fingerprint);
            }
            Vec::new()
        }
    }
}

fn install_prompt(
    model: &mut Model,
    prompt_id: PromptId,
    prompt: Prompt,
    rejection: Option<String>,
) {
    model.pending_prompt = Some(PendingPrompt {
        prompt_id,
        prompt,
        input: String::new(),
        rejection,
    });
}

fn handle_fact(model: &mut Model, at: DateTime<Utc>, fact: &StepFact) -> Vec<Cmd> {
    // Event time from the transcript envelope, rendered in the operator's local
    // zone. Carried on the event so it survives the drip queue: the log column
    // and deviation list show when the event happened, not when it drained.
    let at_local = at.with_timezone(&chrono::Local);

    // Model-specific side-effects first (header, dividers, screen
    // transitions, deviation list). The log feed is fed afterwards from
    // the shared `fact_summary`, so the live log mirrors the console
    // driver line-for-line, except for StepStarted which the divider
    // already represents.
    match fact {
        StepFact::CeremonyStarted { name, .. } => {
            model.ceremony_name = Some(name.clone());
        }
        StepFact::ActStarted { label, .. } => {
            model.push_log(LogLine::ActDivider {
                label: label.clone(),
            });
        }
        StepFact::StepStarted {
            id,
            label,
            role_name,
            ..
        } => {
            // First StepStarted is the natural boundary between pre-step
            // overview and live execution: auto-switch to the Ceremony
            // tab if the operator is still on Overview, so the action
            // narrative is visible without a manual Tab press. Subsequent
            // step transitions don't re-trigger the switch.
            let is_first_step = model.current_step.is_none();
            model.push_log(LogLine::StepDivider {
                label: label.clone(),
                role_name: role_name.clone(),
            });
            model.current_step = Some(StepView {
                id: id.clone(),
                label: label.clone(),
                role_name: role_name.clone(),
            });
            model.pending_prompt = None;
            if is_first_step
                && let Screen::Step {
                    tab: tab @ StepTab::Overview,
                } = &mut model.screen
            {
                *tab = StepTab::Ceremony;
            }
        }
        StepFact::StepCompleted { .. } => {
            model.pending_prompt = None;
        }
        StepFact::DeviationRecorded { step, text } => {
            model.deviations.push(DeviationView {
                step: step.clone(),
                text: text.clone(),
                at: at_local,
            });
        }
        StepFact::CeremonyCompleted { .. } => {
            model.screen = Screen::Completed { fingerprint: None };
            model.running = RunningState::Done;
        }
        StepFact::CeremonyFailed { error, .. } => {
            model.screen = Screen::Failed {
                reason: error.message.clone(),
            };
            model.running = RunningState::Done;
        }
        _ => {}
    }

    // Mirror the console driver's line in the log feed. Skip the facts
    // that already get a dedicated visual divider so we don't echo them
    // as a plain log line right after the divider.
    if !matches!(
        fact,
        StepFact::StepStarted { .. } | StepFact::ActStarted { .. }
    ) && let Some((icon, text)) = fact_summary(fact)
    {
        model.push_entry_at(icon, text, at_local);
    }

    Vec::new()
}

fn handle_signal(model: &mut Model, signal: &UiSignal) -> Vec<Cmd> {
    // Exhaustive (no wildcard) so a new UiSignal variant forces a decision
    // here, matching the protocol's deliberate not-`#[non_exhaustive]` choice.
    match signal {
        UiSignal::CeremonyOverview {
            description,
            materials,
            step_count,
        } => {
            model.ceremony_description.clone_from(description);
            model.ceremony_materials.clone_from(materials);
            model.ceremony_step_count = Some(*step_count);
        }
        UiSignal::SystemInfo(info) => model.system_info = Some((**info).clone()),
        // Re-emittable: a later observer can resend the environment; replace
        // the whole view each time rather than merging.
        UiSignal::Environment(env) => model.environment = Some(env.clone()),
        UiSignal::LogLine { .. } | UiSignal::Progress { .. } => {
            if let Some((icon, text)) = signal_summary(signal) {
                model.push_entry(icon, text);
            }
        }
    }
    Vec::new()
}

#[cfg(test)]
mod tests {
    use rite_model::StepId;
    use rite_runtime::test_support::fact_event;

    use super::*;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::empty())
    }

    /// Push an exec event and drain it from the drip queue with a tick
    /// so unit tests don't have to drive both messages explicitly.
    fn apply_exec(model: &mut Model, event: ExecEvent) -> Vec<Cmd> {
        let _ = update(model, Msg::Exec(event));
        update(model, Msg::Tick)
    }

    #[test]
    fn tick_advances_frame_counter() {
        let mut model = Model::new();
        let cmds = update(&mut model, Msg::Tick);
        assert!(cmds.is_empty());
        assert_eq!(model.tick, 1);
    }

    #[test]
    fn ctrl_c_returns_cmd_quit() {
        let mut model = Model::new();
        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        let cmds = update(&mut model, Msg::Key(event));
        assert!(matches!(cmds.as_slice(), [Cmd::Quit]));
    }

    #[test]
    fn esc_opens_abort_confirm_modal_without_aborting_yet() {
        let mut model = Model::new();
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Esc)));
        assert!(cmds.is_empty());
        assert!(matches!(model.screen, Screen::AbortConfirm));
        assert!(matches!(model.running, RunningState::Active));
    }

    #[test]
    fn abort_confirm_yes_sends_abort_and_closes_modal() {
        let mut model = Model::new();
        let _ = update(&mut model, Msg::Key(key(KeyCode::Esc)));
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('y'))));
        assert!(matches!(model.running, RunningState::Aborting));
        assert!(matches!(
            cmds.as_slice(),
            [Cmd::SendCommand(UiCommand::Abort)]
        ));
        assert!(matches!(model.screen, Screen::Step { .. }));
    }

    #[test]
    fn abort_confirm_no_cancels() {
        let mut model = Model::new();
        let _ = update(&mut model, Msg::Key(key(KeyCode::Esc)));
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('n'))));
        assert!(cmds.is_empty());
        assert!(matches!(model.screen, Screen::Step { .. }));
        assert!(matches!(model.running, RunningState::Active));
    }

    #[test]
    fn deviation_modal_collects_input_then_submits_on_enter() {
        let mut model = Model::new();
        // The deviation key is wired only on the Deviations tab.
        model.screen = Screen::Step {
            tab: StepTab::Deviations,
        };
        model.current_step = Some(StepView {
            id: StepId::new("step1"),
            label: "L".to_string(),
            role_name: "Operator".to_string(),
        });
        // Open the deviation modal.
        let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
        assert!(matches!(model.screen, Screen::DeviationModal { .. }));
        // Type text.
        for c in "phone rang".chars() {
            let _ = update(&mut model, Msg::Key(key(KeyCode::Char(c))));
        }
        // Submit.
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
        match cmds.as_slice() {
            [Cmd::SendCommand(UiCommand::LogDeviation { text })] => {
                assert_eq!(text, "phone rang");
            }
            other => panic!("expected LogDeviation, got {other:?}"),
        }
        assert!(matches!(model.screen, Screen::Step { .. }));
        // The runtime's DeviationRecorded fact (echoed via Msg::Exec)
        // is the single source for the list; the modal submit does not
        // push locally. So the list stays empty until the fact arrives.
        assert!(model.deviations.is_empty());
    }

    #[test]
    fn deviation_modal_escape_cancels_without_emitting() {
        let mut model = Model::new();
        model.screen = Screen::Step {
            tab: StepTab::Deviations,
        };
        let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
        let _ = update(&mut model, Msg::Key(key(KeyCode::Char('x'))));
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Esc)));
        assert!(cmds.is_empty());
        assert!(matches!(model.screen, Screen::Step { .. }));
        assert!(model.deviations.is_empty());
    }

    #[test]
    fn deviation_key_is_ignored_outside_deviations_tab() {
        for tab in [StepTab::Overview, StepTab::Ceremony, StepTab::System] {
            let mut model = Model::new();
            model.screen = Screen::Step { tab };
            let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
            assert!(cmds.is_empty(), "d on {tab:?} must be a no-op");
            assert!(
                matches!(model.screen, Screen::Step { tab: t } if t == tab),
                "d on {tab:?} must not open the modal",
            );
        }
    }

    #[test]
    fn deviation_modal_empty_input_does_not_submit() {
        let mut model = Model::new();
        model.screen = Screen::Step {
            tab: StepTab::Deviations,
        };
        let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
        assert!(cmds.is_empty());
        // Still in the modal.
        assert!(matches!(model.screen, Screen::DeviationModal { .. }));
    }

    #[test]
    fn step_started_pushes_a_divider_into_the_log_feed() {
        let mut model = Model::new();
        let _ = apply_exec(
            &mut model,
            fact_event(StepFact::StepStarted {
                id: StepId::new("s1"),
                label: "2.1".to_string(),
                role: rite_model::RoleId::new("crypto_officer"),
                role_name: "Crypto Officer".to_string(),
            }),
        );
        assert!(matches!(model.screen, Screen::Step { .. }));
        match model.log.front() {
            Some(LogLine::StepDivider { label, role_name }) => {
                assert_eq!(label, "2.1");
                assert_eq!(role_name, "Crypto Officer");
            }
            other => panic!("expected StepDivider, got {other:?}"),
        }
    }

    #[test]
    fn tab_cycles_overview_ceremony_deviations_system() {
        let mut model = Model::new();
        // Default tab is Overview.
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Overview
            }
        ));
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Ceremony
            }
        ));
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Deviations
            }
        ));
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::System
            }
        ));
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Overview
            }
        ));
    }

    #[test]
    fn first_step_auto_switches_overview_to_ceremony() {
        let mut model = Model::new();
        let _ = apply_exec(
            &mut model,
            fact_event(StepFact::StepStarted {
                id: StepId::new("s1"),
                label: "1".to_string(),
                role: rite_model::RoleId::new("op"),
                role_name: "Operator".to_string(),
            }),
        );
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Ceremony
            }
        ));
    }

    #[test]
    fn second_step_does_not_override_manual_tab_choice() {
        let mut model = Model::new();
        // First step transition auto-switches to Ceremony.
        let _ = apply_exec(
            &mut model,
            fact_event(StepFact::StepStarted {
                id: StepId::new("s1"),
                label: "1".to_string(),
                role: rite_model::RoleId::new("op"),
                role_name: "Operator".to_string(),
            }),
        );
        // Operator switches back to Overview to re-read the description.
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); // → Deviations
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); // → System
        let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); // → Overview
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Overview
            }
        ));
        // Second step must not yank the operator off Overview.
        let _ = apply_exec(
            &mut model,
            fact_event(StepFact::StepStarted {
                id: StepId::new("s2"),
                label: "2".to_string(),
                role: rite_model::RoleId::new("op"),
                role_name: "Operator".to_string(),
            }),
        );
        assert!(matches!(
            model.screen,
            Screen::Step {
                tab: StepTab::Overview
            }
        ));
    }

    #[test]
    fn ceremony_started_fact_populates_only_the_name() {
        let mut model = Model::new();
        let _ = apply_exec(
            &mut model,
            fact_event(StepFact::CeremonyStarted {
                name: "Root CA".to_string(),
            }),
        );
        assert_eq!(model.ceremony_name.as_deref(), Some("Root CA"));
        // Description, materials, step count travel via UiSignal::CeremonyOverview,
        // not the fact: the transcript intentionally doesn't carry them.
        assert!(model.ceremony_description.is_none());
        assert!(model.ceremony_materials.is_empty());
        assert!(model.ceremony_step_count.is_none());
    }

    #[test]
    fn ceremony_overview_signal_populates_metadata() {
        let mut model = Model::new();
        let _ = apply_exec(
            &mut model,
            ExecEvent::Signal(UiSignal::CeremonyOverview {
                description: Some("Generate offline root.".to_string()),
                materials: vec![rite_runtime::MaterialOverview {
                    id: rite_model::MaterialId::new("yk"),
                    title: Some("YubiKey".to_string()),
                    description: None,
                    kind: rite_runtime::MaterialOverviewKind::Digital,
                }],
                step_count: 5,
            }),
        );
        assert_eq!(
            model.ceremony_description.as_deref(),
            Some("Generate offline root."),
        );
        assert_eq!(model.ceremony_step_count, Some(5));
        assert_eq!(model.ceremony_materials.len(), 1);
        assert_eq!(
            model
                .ceremony_materials
                .first()
                .expect("material populated")
                .display_title(),
            "YubiKey",
        );
    }

    #[test]
    fn step_started_installs_current_step() {
        let mut model = Model::new();
        let fact = StepFact::StepStarted {
            id: StepId::new("s1"),
            label: "Step One".to_string(),
            role: rite_model::RoleId::new("op"),
            role_name: "Operator".to_string(),
        };
        let _ = apply_exec(&mut model, fact_event(fact));
        let s = model.current_step.expect("current step");
        assert_eq!(s.id, StepId::new("s1"));
        assert_eq!(s.label, "Step One");
    }

    #[test]
    fn confirm_y_responds_with_true() {
        let mut model = Model::new();
        // Prompt routing only fires on the Ceremony tab; the default tab
        // is Overview which deliberately ignores the prompt.
        model.screen = Screen::Step {
            tab: StepTab::Ceremony,
        };
        install_prompt(
            &mut model,
            PromptId::new(1),
            Prompt::Confirm {
                question: "ok?".to_string(),
                default: None,
            },
            None,
        );
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('y'))));
        match cmds.as_slice() {
            [
                Cmd::SendCommand(UiCommand::PromptResponse {
                    prompt_id,
                    response: Response::Bool(true),
                }),
            ] => assert_eq!(*prompt_id, PromptId::new(1)),
            _ => panic!("expected Bool(true) response"),
        }
        assert!(model.pending_prompt.is_none());
    }

    #[test]
    fn text_prompt_collects_input_then_sends_on_enter() {
        let mut model = Model::new();
        model.screen = Screen::Step {
            tab: StepTab::Ceremony,
        };
        install_prompt(
            &mut model,
            PromptId::new(2),
            Prompt::Text {
                label: "name".to_string(),
                validator: rite_model::ValidatorSpec::NonEmpty,
            },
            None,
        );
        for c in "Alice".chars() {
            let _ = update(&mut model, Msg::Key(key(KeyCode::Char(c))));
        }
        let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
        match cmds.as_slice() {
            [
                Cmd::SendCommand(UiCommand::PromptResponse {
                    response: Response::Text(t),
                    ..
                }),
            ] => assert_eq!(t, "Alice"),
            _ => panic!("expected Text response"),
        }
    }

    #[test]
    fn ceremony_completed_then_finalized_populates_fingerprint() {
        let mut model = Model::new();
        let fact = StepFact::CeremonyCompleted {};
        let _ = apply_exec(&mut model, fact_event(fact));
        assert!(matches!(
            model.screen,
            Screen::Completed {
                fingerprint: None,
                ..
            }
        ));
        assert!(matches!(model.running, RunningState::Done));

        let _ = apply_exec(
            &mut model,
            ExecEvent::Finalized {
                fingerprint: "sha256:abc".to_string(),
            },
        );
        match &model.screen {
            Screen::Completed {
                fingerprint: Some(fp),
                ..
            } => assert_eq!(fp, "sha256:abc"),
            other => panic!("expected fingerprinted Completed, got {other:?}"),
        }
    }

    #[test]
    fn quit_flushes_queued_terminal_fact_so_completion_screen_shows() {
        let mut model = Model::new();
        // At ceremony end the executor emits CeremonyCompleted then the
        // Finalized fingerprint and closes the channel; the forwarder turns
        // the close into Msg::Quit. The two facts are still queued in the
        // drip buffer when Quit arrives.
        let _ = update(
            &mut model,
            Msg::Exec(fact_event(StepFact::CeremonyCompleted {})),
        );
        let _ = update(
            &mut model,
            Msg::Exec(ExecEvent::Finalized {
                fingerprint: "sha256:abc".to_string(),
            }),
        );
        assert_eq!(model.pending_events.len(), 2);
        assert!(!model.screen.is_terminal());

        // Quit must flush the queue first, so it lands on the Completed
        // screen (with the fingerprint) instead of tearing the TUI down.
        let cmds = update(&mut model, Msg::Quit);
        assert!(
            cmds.is_empty(),
            "Quit must not tear down before the completion screen is shown",
        );
        assert!(model.pending_events.is_empty());
        match &model.screen {
            Screen::Completed {
                fingerprint: Some(fp),
                ..
            } => assert_eq!(fp, "sha256:abc"),
            other => panic!("expected fingerprinted Completed, got {other:?}"),
        }
    }

    #[test]
    fn quit_mid_ceremony_still_tears_down() {
        // Channel closed without a terminal fact (e.g. executor crash): with
        // nothing terminal to show, Quit should still request teardown.
        let mut model = Model::new();
        let cmds = update(&mut model, Msg::Quit);
        assert!(matches!(cmds.as_slice(), [Cmd::Quit]));
    }

    #[test]
    fn exec_event_queues_until_next_tick() {
        let mut model = Model::new();
        let signal = UiSignal::LogLine {
            step: None,
            icon: rite_runtime::Icon::Info,
            text: "queued".to_string(),
        };
        // Msg::Exec alone leaves the log empty and the queue non-empty.
        let _ = update(&mut model, Msg::Exec(ExecEvent::Signal(signal)));
        assert!(model.log.is_empty());
        assert_eq!(model.pending_events.len(), 1);

        // The next Tick drains one event.
        let _ = update(&mut model, Msg::Tick);
        assert!(model.pending_events.is_empty());
        assert_eq!(model.log.len(), 1);
    }

    #[test]
    fn log_signal_pushes_log_line() {
        let mut model = Model::new();
        let signal = UiSignal::LogLine {
            step: None,
            icon: rite_runtime::Icon::Info,
            text: "hi".to_string(),
        };
        let _ = apply_exec(&mut model, ExecEvent::Signal(signal));
        assert_eq!(model.log.len(), 1);
        match model.log.front() {
            Some(LogLine::Entry { text, .. }) => assert_eq!(text, "hi"),
            other => panic!("expected Entry, got {other:?}"),
        }
    }
}
