#![expect(
    clippy::disallowed_methods,
    reason = "sync CLI run-progress renderer: writes to std::io::stderr directly"
)]

use fabro_types::{RunEvent, RunNoticeCode};

mod event;
mod info_display;
mod renderer;
mod setup_display;
mod stage_display;
mod styles;

use event::{ProgressEvent, from_json_line, from_run_event};
use info_display::InfoDisplay;
use renderer::ProgressRenderer;
use setup_display::SetupDisplay;
use stage_display::StageDisplay;

pub(crate) struct ProgressUI {
    renderer: ProgressRenderer,
    stage: StageDisplay,
    setup: SetupDisplay,
    info: InfoDisplay,
    saw_metadata_snapshot_failure: bool,
}

impl ProgressUI {
    pub(crate) fn new(is_tty: bool, verbose: bool) -> Self {
        let renderer = if is_tty {
            ProgressRenderer::new_tty()
        } else {
            ProgressRenderer::new_plain(
                Box::new(std::io::stderr()),
                console::colors_enabled_stderr(),
            )
        };
        Self::with_renderer(renderer, verbose)
    }

    fn with_renderer(renderer: ProgressRenderer, verbose: bool) -> Self {
        Self {
            renderer,
            stage: StageDisplay::new(verbose),
            setup: SetupDisplay::new(verbose),
            info: InfoDisplay::new(verbose),
            saw_metadata_snapshot_failure: false,
        }
    }

    #[cfg(test)]
    #[expect(
        clippy::disallowed_types,
        reason = "test helper accepts a sync blocking writer to capture rendered output"
    )]
    fn new_plain_test(out: Box<dyn std::io::Write + Send>, verbose: bool, colors: bool) -> Self {
        Self::with_renderer(ProgressRenderer::new_plain(out, colors), verbose)
    }

    pub(crate) fn set_working_directory(&mut self, dir: String) {
        self.stage.set_working_directory(dir);
    }

    pub(crate) fn hide_bars(&self) {
        self.renderer.hide();
    }

    pub(crate) fn show_bars(&self) {
        self.renderer.show();
    }

    pub(crate) fn finish(&mut self) {
        self.stage.finish();
        self.setup.finish();
        self.renderer.finish();
    }

    #[cfg_attr(
        not(test),
        allow(
            dead_code,
            reason = "Production code drives this via JSON lines; tests call it directly."
        )
    )]
    pub(crate) fn handle_event(&mut self, event: &RunEvent) {
        if let Some(progress_event) = from_run_event(event) {
            self.dispatch(progress_event);
        }
    }

    pub(crate) fn handle_json_line(&mut self, line: &str) {
        if let Some(progress_event) = from_json_line(line) {
            self.dispatch(progress_event);
        }
    }

    fn dispatch(&mut self, event: ProgressEvent) {
        let renderer = &self.renderer;
        match event {
            ProgressEvent::RunCreated { web_url } => {
                if let Some(url) = web_url {
                    InfoDisplay::show_web_url(renderer, &url);
                }
            }
            ProgressEvent::WorkflowStarted {
                worktree_dir,
                base_branch,
                base_sha,
            } => {
                if let Some(worktree_dir) = worktree_dir {
                    InfoDisplay::show_worktree(renderer, std::path::Path::new(&worktree_dir));
                }
                if let Some(base_sha) = base_sha {
                    InfoDisplay::show_base_info(renderer, base_branch.as_deref(), &base_sha);
                }
            }
            ProgressEvent::WorkingDirectorySet { working_directory } => {
                self.set_working_directory(working_directory);
            }
            ProgressEvent::SandboxInitializing { provider } => {
                self.setup.on_sandbox_initializing(renderer, &provider);
            }
            ProgressEvent::SandboxFailed { provider, error } => {
                self.setup.on_sandbox_failed(renderer, &provider, &error);
            }
            ProgressEvent::SnapshotPulling { name } => {
                self.setup.on_snapshot_pulling(renderer, &name);
            }
            ProgressEvent::SnapshotCreating { name } => {
                self.setup.on_snapshot_creating(renderer, &name);
            }
            ProgressEvent::SnapshotReady { name, duration_ms } => {
                self.setup.on_snapshot_ready(renderer, &name, duration_ms);
            }
            ProgressEvent::SnapshotFailed { name, error } => {
                self.setup.on_snapshot_failed(renderer, &name, &error);
            }
            ProgressEvent::SandboxReady {
                provider,
                duration_ms,
                name,
                cpu,
                memory,
                url,
            } => {
                self.setup.on_sandbox_ready(
                    renderer,
                    &provider,
                    duration_ms,
                    name.as_deref(),
                    cpu,
                    memory,
                    url.as_deref(),
                );
            }
            ProgressEvent::SshAccessReady { ssh_command } => {
                SetupDisplay::on_ssh_access_ready(renderer, &ssh_command);
            }
            ProgressEvent::SetupStarted { command_count } => {
                self.setup.on_setup_started(renderer, command_count);
            }
            ProgressEvent::SetupCompleted { duration_ms } => {
                self.setup.on_setup_completed(renderer, duration_ms);
            }
            ProgressEvent::SetupCommandCompleted {
                command,
                command_index,
                exit_code,
                duration_ms,
            } => {
                self.setup.on_setup_command_completed(
                    renderer,
                    &command,
                    command_index,
                    exit_code,
                    duration_ms,
                );
            }
            ProgressEvent::CliEnsureStarted { cli_name } => {
                self.setup.on_cli_ensure_started(renderer, &cli_name);
            }
            ProgressEvent::CliEnsureCompleted {
                cli_name,
                already_installed,
                duration_ms,
            } => {
                self.setup.on_cli_ensure_completed(
                    renderer,
                    &cli_name,
                    already_installed,
                    duration_ms,
                );
            }
            ProgressEvent::CliEnsureFailed { cli_name } => {
                self.setup.on_cli_ensure_failed(renderer, &cli_name);
            }
            ProgressEvent::StageStarted {
                node_id,
                name,
                script,
            } => {
                self.stage
                    .on_stage_started(renderer, &node_id, &name, script.as_deref());
            }
            ProgressEvent::StageCompleted {
                node_id,
                name,
                timing,
                status,
                usage,
            } => {
                self.stage.on_stage_completed(
                    renderer,
                    &node_id,
                    &name,
                    timing.wall_time_ms,
                    &status,
                    usage.as_ref(),
                );
            }
            ProgressEvent::StageFailed {
                node_id,
                name,
                error,
            } => {
                self.stage
                    .on_stage_failed(renderer, &node_id, &name, &error);
            }
            ProgressEvent::StageRetrying {
                name,
                attempt,
                max_attempts,
                delay_ms,
            } => {
                self.info
                    .on_stage_retrying(renderer, &name, attempt, max_attempts, delay_ms);
            }
            ProgressEvent::ParallelStarted => {
                self.stage.on_parallel_started();
            }
            ProgressEvent::ParallelBranchStarted { branch } => {
                self.stage.on_parallel_branch_started(renderer, &branch);
            }
            ProgressEvent::ParallelBranchCompleted {
                branch,
                duration_ms,
                status,
            } => {
                self.stage
                    .on_parallel_branch_completed(renderer, &branch, duration_ms, status);
            }
            ProgressEvent::ParallelCompleted => {
                self.stage.on_parallel_completed();
            }
            ProgressEvent::AssistantMessage {
                stage_node_id,
                model,
                root_session,
            } => {
                if root_session {
                    self.stage.on_llm_request_finished(&stage_node_id);
                }
                self.stage
                    .on_assistant_message(renderer, &stage_node_id, &model);
            }
            ProgressEvent::ToolCallStarted {
                stage_node_id,
                tool_name,
                tool_call_id,
                arguments,
                timestamp,
            } => {
                self.stage.on_tool_call_started(
                    renderer,
                    &stage_node_id,
                    &tool_name,
                    &tool_call_id,
                    &arguments,
                    timestamp,
                );
            }
            ProgressEvent::ToolCallCompleted {
                stage_node_id,
                tool_call_id,
                is_error,
                duration_ms,
                timestamp,
            } => {
                self.stage.on_tool_call_completed(
                    renderer,
                    &stage_node_id,
                    &tool_call_id,
                    is_error,
                    duration_ms,
                    timestamp,
                );
            }
            ProgressEvent::ContextWindowWarning {
                stage_node_id,
                usage_percent,
            } => {
                self.stage
                    .on_context_window_warning(renderer, &stage_node_id, usage_percent);
            }
            ProgressEvent::CompactionStarted { stage_node_id } => {
                self.stage.on_compaction_started(renderer, &stage_node_id);
            }
            ProgressEvent::CompactionCompleted {
                stage_node_id,
                original_turn_count,
                preserved_turn_count,
                tracked_file_count,
            } => {
                self.stage.on_compaction_completed(
                    renderer,
                    &stage_node_id,
                    original_turn_count,
                    preserved_turn_count,
                    tracked_file_count,
                );
            }
            ProgressEvent::CompactionFailed {
                stage_node_id,
                error,
                root_session,
            } => {
                if root_session {
                    self.stage.on_llm_request_finished(&stage_node_id);
                }
                self.stage
                    .on_compaction_failed(renderer, &stage_node_id, &error);
            }
            ProgressEvent::LlmRequestStarted {
                stage_node_id,
                model,
            } => {
                self.stage
                    .on_llm_request_started(renderer, &stage_node_id, &model);
            }
            ProgressEvent::LlmFirstOutput {
                stage_node_id,
                kind,
            } => {
                self.stage.on_llm_first_output(&stage_node_id, kind);
            }
            ProgressEvent::LlmRetry {
                stage_node_id,
                model,
                attempt,
                delay_ms,
                error,
            } => {
                self.stage.on_llm_retry(
                    renderer,
                    &stage_node_id,
                    &model,
                    attempt,
                    delay_ms,
                    &error,
                );
            }
            ProgressEvent::LlmRequestFinished { stage_node_id } => {
                self.stage.on_llm_request_finished(&stage_node_id);
            }
            ProgressEvent::SubagentStarted {
                stage_node_id,
                agent_id,
                task,
                generation,
            } => {
                self.stage.on_subagent_started(
                    renderer,
                    &stage_node_id,
                    &agent_id,
                    &task,
                    generation,
                );
            }
            ProgressEvent::SubagentCompleted {
                stage_node_id,
                agent_id,
                success,
                turns_used,
            } => {
                self.stage.on_subagent_completed(
                    renderer,
                    &stage_node_id,
                    &agent_id,
                    success,
                    turns_used,
                );
            }
            ProgressEvent::EdgeSelected {
                from_node,
                to_node,
                label,
                condition,
            } => {
                self.info.on_edge_selected(
                    renderer,
                    &from_node,
                    &to_node,
                    label.as_deref(),
                    condition.as_deref(),
                );
            }
            ProgressEvent::LoopRestart { from_node, to_node } => {
                self.info.on_loop_restart(renderer, &from_node, &to_node);
            }
            ProgressEvent::MetadataSnapshotFailed {
                phase,
                failure_kind,
                error,
            } => {
                self.saw_metadata_snapshot_failure = true;
                InfoDisplay::on_metadata_snapshot_failed(renderer, &phase, &failure_kind, &error);
            }
            ProgressEvent::RunNotice {
                level,
                code,
                message,
            } => {
                if self.saw_metadata_snapshot_failure
                    && code
                        .parse::<RunNoticeCode>()
                        .ok()
                        .is_some_and(RunNoticeCode::is_metadata_snapshot_compat)
                {
                    return;
                }
                InfoDisplay::on_run_notice(renderer, level, &code, &message);
            }
            ProgressEvent::PullRequestCreated { pr_url, draft } => {
                InfoDisplay::on_pull_request_created(renderer, &pr_url, draft);
            }
            ProgressEvent::PullRequestFailed { error } => {
                InfoDisplay::on_pull_request_failed(renderer, &error);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    #![allow(
        clippy::absolute_paths,
        clippy::needless_pass_by_value,
        reason = "These run-progress tests prefer explicit fixtures over pedantic style lints."
    )]
    #![expect(
        clippy::disallowed_types,
        reason = "These tests capture rendered output in Vec<u8> buffers."
    )]

    use std::io::{self, Write};
    use std::sync::{Arc, Mutex};

    use chrono::{DateTime, Utc};
    use fabro_agent::{AgentEvent, SandboxEvent};
    use fabro_llm::types::TokenCounts;
    use fabro_model::{Catalog, ModelRef, ProviderId};
    use fabro_types::run_event::CliEnsureCompletedProps;
    use fabro_types::{
        MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind,
        StageId, fixtures,
    };
    use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
    use fabro_workflow::outcome::billed_model_usage_from_llm;

    use super::*;
    use crate::commands::run::run_progress::stage_display::ToolCallStatus;

    struct SharedBuffer {
        inner: Arc<Mutex<Vec<u8>>>,
    }

    impl Write for SharedBuffer {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.inner
                .lock()
                .expect("buffer lock poisoned")
                .extend_from_slice(buf);
            Ok(buf.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    fn capture_ui(verbose: bool) -> (ProgressUI, Arc<Mutex<Vec<u8>>>) {
        let buffer = Arc::new(Mutex::new(Vec::new()));
        let ui = ProgressUI::new_plain_test(
            Box::new(SharedBuffer {
                inner: Arc::clone(&buffer),
            }),
            verbose,
            false,
        );
        (ui, buffer)
    }

    fn rendered(buffer: &Arc<Mutex<Vec<u8>>>) -> String {
        String::from_utf8(buffer.lock().expect("buffer lock poisoned").clone())
            .expect("valid utf-8")
    }

    fn emit(ui: &mut ProgressUI, event: Event) {
        let stored = to_run_event(&fixtures::RUN_1, &event);
        ui.handle_event(&stored);
    }

    fn emit_ref(ui: &mut ProgressUI, event: &Event) {
        let stored = to_run_event(&fixtures::RUN_1, event);
        ui.handle_event(&stored);
    }

    fn emit_body(ui: &mut ProgressUI, body: fabro_types::EventBody) {
        ui.handle_event(&RunEvent {
            id: "evt_legacy".to_string(),
            ts: Utc::now(),
            run_id: fixtures::RUN_1,
            node_id: None,
            node_label: None,
            stage_id: None,
            parallel_group_id: None,
            parallel_branch_id: None,
            session_id: None,
            parent_session_id: None,
            tool_call_id: None,
            actor: None,
            body,
        });
    }

    fn agent_event(stage: &str, event: AgentEvent) -> Event {
        Event::Agent {
            stage: stage.into(),
            visit: 1,
            event,
            session_id: None,
            parent_session_id: None,
            tool_call_id: None,
        }
    }

    fn child_agent_event(stage: &str, event: AgentEvent) -> Event {
        Event::Agent {
            stage: stage.into(),
            visit: 1,
            event,
            session_id: Some("ses_child".into()),
            parent_session_id: Some("ses_root".into()),
            tool_call_id: None,
        }
    }

    fn stage_started(node_id: &str, name: &str) -> Event {
        Event::StageStarted {
            graph_visit:           None,
            resumed_from_stage_id: None,
            node_id:               node_id.into(),
            name:                  name.into(),
            index:                 0,
            handler_type:          String::new(),
            attempt:               1,
            max_attempts:          1,
        }
    }

    fn assistant_event(model: &str, text: &str) -> AgentEvent {
        AgentEvent::AssistantMessage {
            text:            text.into(),
            model:           ModelRef {
                provider: ProviderId::openai(),
                model_id: model.into(),
                speed:    None,
            },
            usage:           TokenCounts::default(),
            cost_usd:        None,
            cost_source:     None,
            tool_call_count: 0,
            context_window:  None,
            reasoning:       None,
        }
    }

    fn assistant_message(stage: &str, model: &str) -> Event {
        agent_event(stage, assistant_event(model, "done"))
    }

    fn child_assistant_message(stage: &str, model: &str) -> Event {
        child_agent_event(stage, assistant_event(model, "child done"))
    }

    fn llm_request_started(stage: &str, model: &str) -> Event {
        agent_event(stage, AgentEvent::LlmRequestStarted {
            requested_model: ModelRef {
                provider: ProviderId::anthropic(),
                model_id: model.into(),
                speed:    None,
            },
        })
    }

    fn stage_completed(node_id: &str, name: &str) -> Event {
        Event::StageCompleted {
            node_id: node_id.into(),
            name: name.into(),
            index: 0,
            timing: fabro_types::StageTiming::wall_only(5000),
            status: "succeeded".into(),
            preferred_label: None,
            suggested_next_ids: Vec::new(),
            billing: Some(
                billed_model_usage_from_llm(
                    Catalog::builtin(),
                    &ModelRef {
                        provider: ProviderId::openai(),
                        model_id: "gpt-5-mini".into(),
                        speed:    None,
                    },
                    &TokenCounts {
                        input_tokens: 1200,
                        output_tokens: 300,
                        ..TokenCounts::default()
                    },
                )
                .unwrap(),
            ),
            failure: None,
            notes: None,
            files_touched: Vec::new(),
            context_updates: None,
            jump_to_node: None,
            context_values: None,
            node_visits: None,
            loop_failure_signatures: None,
            restart_failure_signatures: None,
            response: None,
            attempt: 1,
            max_attempts: 1,
        }
    }

    #[test]
    fn parallel_branches_tracked_as_tool_calls() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("fork1", "Fork Analysis"));
        assert!(ui.stage.active_stages.contains_key("fork1"));
        assert!(ui.stage.parallel_parent.is_none());

        emit(&mut ui, Event::ParallelStarted {
            node_id:      "fork1".into(),
            visit:        1,
            branch_count: 2,
        });
        assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1"));

        emit(&mut ui, Event::ParallelBranchStarted {
            graph_visit:           None,
            resumed_from_stage_id: None,
            parallel_group_id:     StageId::new("fork1", 1),
            parallel_branch_id:    ParallelBranchId::new(StageId::new("fork1", 1), 0),
            branch:                "security".into(),
            index:                 0,
            item_label:            Some("auth".into()),
        });
        let stage = &ui.stage.active_stages["fork1"];
        assert_eq!(stage.tool_calls.len(), 1);
        assert_eq!(stage.tool_calls[0].tool_call_id, "auth (security #0)");
        assert!(matches!(
            stage.tool_calls[0].status,
            ToolCallStatus::Running
        ));

        emit(&mut ui, Event::ParallelBranchCompleted {
            parallel_group_id:  StageId::new("fork1", 1),
            parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
            branch:             "security".into(),
            index:              0,
            item_label:         Some("auth".into()),
            duration_ms:        2000,
            status:             fabro_workflow::outcome::StageOutcome::Succeeded,
        });
        let stage = &ui.stage.active_stages["fork1"];
        assert!(matches!(
            stage.tool_calls[0].status,
            ToolCallStatus::Succeeded
        ));
    }

    #[test]
    fn parallel_branch_running_shows_triangle_glyph() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("fork1", "Fork"));
        emit(&mut ui, Event::ParallelStarted {
            node_id:      "fork1".into(),
            visit:        1,
            branch_count: 1,
        });
        emit(&mut ui, Event::ParallelBranchStarted {
            graph_visit:           None,
            resumed_from_stage_id: None,
            parallel_group_id:     StageId::new("fork1", 1),
            parallel_branch_id:    ParallelBranchId::new(StageId::new("fork1", 1), 0),
            branch:                "security".into(),
            index:                 0,
            item_label:            None,
        });

        let stage = &ui.stage.active_stages["fork1"];
        let message = stage.tool_calls[0].bar.message();
        assert!(
            message.contains('\u{25b8}'),
            "expected branch message to contain ▸, got: {message:?}"
        );
    }

    #[test]
    fn compaction_sets_and_clears_bar() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        assert!(ui.stage.active_stages["s1"].compaction_bar.is_none());

        emit(
            &mut ui,
            agent_event("s1", AgentEvent::CompactionStarted {
                estimated_tokens:    5000,
                context_window_size: 8000,
            }),
        );
        assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());
        emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
        assert!(ui.stage.active_stages["s1"].inference_bar.is_some());

        emit(
            &mut ui,
            agent_event("s1", AgentEvent::CompactionCompleted {
                original_turn_count:    20,
                preserved_turn_count:   6,
                summary_token_estimate: 500,
                tracked_file_count:     3,
            }),
        );
        assert!(ui.stage.active_stages["s1"].compaction_bar.is_none());
    }

    #[test]
    fn compaction_failure_clears_bar() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        emit(
            &mut ui,
            agent_event("s1", AgentEvent::CompactionStarted {
                estimated_tokens:    5000,
                context_window_size: 8000,
            }),
        );
        assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());

        emit(
            &mut ui,
            agent_event("s1", AgentEvent::Error {
                error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary {
                    summarized_turn_count: 14,
                }),
            }),
        );

        assert!(ui.stage.active_stages["s1"].compaction_bar.is_none());
        assert!(ui.stage.active_stages["s1"].inference_bar.is_none());
    }

    #[test]
    fn plain_compaction_failure_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(
            &mut ui,
            agent_event("s1", AgentEvent::Error {
                error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary {
                    summarized_turn_count: 14,
                }),
            }),
        );

        insta::assert_snapshot!(rendered(&buffer), @"      ✗ compaction failed: generated summary was empty after trimming; refused to replace 14 turns and left history intact");
    }

    #[test]
    fn inference_bracket_sets_updates_and_clears_bar() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        assert!(ui.stage.active_stages["s1"].inference_bar.is_none());

        emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
        let message = ui.stage.active_stages["s1"]
            .inference_bar
            .as_ref()
            .expect("bracket should open a live line")
            .message();
        assert!(
            message.contains("waiting on claude-fable-5"),
            "expected the requested model, got: {message:?}"
        );

        emit(
            &mut ui,
            agent_event("s1", AgentEvent::LlmFirstOutput {
                kind: fabro_types::LlmOutputKind::ToolCall,
            }),
        );
        let message = ui.stage.active_stages["s1"]
            .inference_bar
            .as_ref()
            .expect("the line stays open until the round ends")
            .message();
        assert!(
            message.contains("calling tools"),
            "expected the observed output kind, got: {message:?}"
        );

        emit(&mut ui, assistant_message("s1", "claude-fable-5"));
        assert!(ui.stage.active_stages["s1"].inference_bar.is_none());
    }

    #[test]
    fn inference_retry_resets_the_live_line_before_verbose_output() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
        emit(
            &mut ui,
            agent_event("s1", AgentEvent::LlmFirstOutput {
                kind: fabro_types::LlmOutputKind::Text,
            }),
        );
        emit(
            &mut ui,
            agent_event("s1", AgentEvent::LlmRetry {
                provider:   "anthropic".into(),
                model:      "claude-fable-5".into(),
                attempt:    1,
                delay_secs: 0.1,
                phase:      fabro_types::LlmRetryPhase::Consume,
                error:      fabro_llm::Error::Configuration {
                    message: "retry".into(),
                    source:  None,
                },
            }),
        );

        let message = ui.stage.active_stages["s1"]
            .inference_bar
            .as_ref()
            .expect("retry keeps the bracket open")
            .message();
        assert!(message.contains("waiting on claude-fable-5"));
    }

    #[test]
    fn inference_interrupt_clears_the_live_line() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
        emit(
            &mut ui,
            agent_event("s1", AgentEvent::RoundInterrupted { generation: 1 }),
        );

        assert!(ui.stage.active_stages["s1"].inference_bar.is_none());
    }

    #[test]
    fn child_session_events_do_not_mutate_the_root_inference_line() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("s1", "Build"));
        emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
        emit(
            &mut ui,
            child_agent_event("s1", AgentEvent::LlmFirstOutput {
                kind: fabro_types::LlmOutputKind::ToolCall,
            }),
        );
        emit(&mut ui, child_assistant_message("s1", "child-model"));

        let message = ui.stage.active_stages["s1"]
            .inference_bar
            .as_ref()
            .expect("child output must not close the root bracket")
            .message();
        assert!(message.contains("waiting on claude-fable-5"));

        emit(&mut ui, assistant_message("s1", "claude-fable-5"));
        assert!(ui.stage.active_stages["s1"].inference_bar.is_none());
    }

    #[test]
    fn handle_json_line_ignores_invalid_json() {
        let (mut ui, buffer) = capture_ui(false);
        ui.handle_json_line("not valid json");
        ui.handle_json_line("");
        ui.handle_json_line("{}");
        assert!(rendered(&buffer).is_empty());
    }

    #[test]
    fn handle_json_line_matches_handle_event_for_verbose_events() {
        let events = vec![
            stage_started("code", "Code"),
            Event::SandboxInitialized {
                working_directory: "/home/daytona/workspace".into(),
                provider:          SandboxProviderKind::Daytona,
                id:                "daytona:sandbox-id".into(),
                repo_cloned:       None,
                clone_origin_url:  None,
                clone_branch:      None,
                workspace_root:    None,
                repos_root:        None,
                primary_repo_path: None,
                primary_repo_link: None,
                image:             None,
                snapshot:          None,
            },
            agent_event("code", AgentEvent::ToolCallStarted {
                tool_name:    "read_file".into(),
                tool_call_id: "tc1".into(),
                arguments:    serde_json::json!({
                    "file_path": "/home/daytona/workspace/src/main.rs"
                }),
            }),
            assistant_message("code", "gpt-5-mini"),
            Event::EdgeSelected {
                from_node:          "code".into(),
                to_node:            "review".into(),
                label:              Some("ship".into()),
                condition:          None,
                reason:             "condition".into(),
                preferred_label:    None,
                suggested_next_ids: Vec::new(),
                stage_status:       "succeeded".into(),
                is_jump:            false,
            },
            Event::StageRetrying {
                node_id:      "code".into(),
                name:         "Code".into(),
                index:        0,
                attempt:      2,
                max_attempts: 3,
                delay_ms:     1500,
            },
            agent_event("code", AgentEvent::Warning {
                kind:    "context_window".into(),
                message: "high usage".into(),
                details: serde_json::json!({"usage_percent": 92}),
            }),
            agent_event("code", AgentEvent::LlmRetry {
                provider:   "openai".into(),
                model:      "gpt-5-mini".into(),
                attempt:    2,
                delay_secs: 1.5,
                phase:      fabro_types::LlmRetryPhase::Open,
                error:      fabro_llm::Error::Configuration {
                    message: "busy".into(),
                    source:  None,
                },
            }),
            agent_event("code", AgentEvent::SubAgentSpawned {
                agent_id:   "a1".into(),
                depth:      1,
                task:       "review recent changes".into(),
                generation: 1,
            }),
            agent_event("code", AgentEvent::SubAgentCompleted {
                agent_id:   "a1".into(),
                depth:      1,
                generation: 1,
                success:    true,
                turns_used: 3,
            }),
            Event::SetupStarted { command_count: 1 },
            Event::SetupCommandCompleted {
                command:     "bun install".into(),
                index:       0,
                exit_code:   0,
                duration_ms: 2200,
            },
            Event::SetupCompleted { duration_ms: 2200 },
        ];

        let (mut event_ui, event_buffer) = capture_ui(true);
        for event in &events {
            emit_ref(&mut event_ui, event);
        }

        let (mut json_ui, json_buffer) = capture_ui(true);
        for event in &events {
            let line = serde_json::to_string(&to_run_event(&fixtures::RUN_1, event)).unwrap();
            json_ui.handle_json_line(&line);
        }

        assert_eq!(rendered(&event_buffer), rendered(&json_buffer));
    }

    #[test]
    fn plain_default_stage_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, stage_started("plan", "Plan"));
        emit(&mut ui, assistant_message("plan", "gpt-5-mini"));
        emit(
            &mut ui,
            agent_event("plan", AgentEvent::ToolCallStarted {
                tool_name:    "read_file".into(),
                tool_call_id: "tc1".into(),
                arguments:    serde_json::json!({"path": "src/main.rs"}),
            }),
        );
        emit(
            &mut ui,
            agent_event("plan", AgentEvent::ToolCallCompleted {
                tool_name:             "read_file".into(),
                tool_call_id:          "tc1".into(),
                output:                serde_json::json!({"ok": true}),
                is_error:              false,
                output_bytes_observed: 11,
                output_bytes_retained: 11,
                output_bytes_omitted:  0,
            }),
        );
        emit(&mut ui, stage_completed("plan", "Plan"));

        insta::assert_snapshot!(rendered(&buffer), @"    ✓ Plan  5s");
    }

    #[test]
    fn plain_default_setup_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "daytona".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Ready {
                provider:    "daytona".into(),
                duration_ms: 2500,
                name:        Some("sandbox-1".into()),
                cpu:         Some(4.0),
                memory:      Some(8.0),
                url:         None,
            },
        });
        emit(&mut ui, Event::SshAccessReady {
            ssh_command: "ssh daytona@example".into(),
        });
        emit(&mut ui, Event::SetupStarted { command_count: 2 });
        emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 });
        emit_body(
            &mut ui,
            fabro_types::EventBody::CliEnsureCompleted(CliEnsureCompletedProps {
                cli_name:          "gh".into(),
                provider:          "github".into(),
                already_installed: false,
                node_installed:    false,
                duration_ms:       600,
            }),
        );
        insta::assert_snapshot!(rendered(&buffer), @r"
            Sandbox: daytona (ready in 2s)
                     sandbox-1 (4 cpu, 8 GB)
                     ssh daytona@example
            Setup: 2 commands (8s)
            CLI: gh (installed, 600ms)
        ");
    }

    #[test]
    fn plain_daytona_snapshot_creation_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "daytona".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotCreating {
                name: "fabro-v9-test".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotReady {
                name:        "fabro-v9-test".into(),
                duration_ms: 210_000,
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Ready {
                provider:    "daytona".into(),
                duration_ms: 212_000,
                name:        Some("sandbox-1".into()),
                cpu:         Some(4.0),
                memory:      Some(8.0),
                url:         None,
            },
        });

        insta::assert_snapshot!(rendered(&buffer), @r"
            Sandbox: building fabro-v9-test...
            Sandbox: daytona (ready in 3m32s)
                     sandbox-1 (4 cpu, 8 GB)
        ");
    }

    #[test]
    fn plain_docker_snapshot_pull_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "docker".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotPulling {
                name: "buildpack-deps:noble".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotReady {
                name:        "buildpack-deps:noble".into(),
                duration_ms: 8_200,
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Ready {
                provider:    "docker".into(),
                duration_ms: 9_000,
                name:        None,
                cpu:         None,
                memory:      None,
                url:         None,
            },
        });

        insta::assert_snapshot!(rendered(&buffer), @r"
            Sandbox: pulling buildpack-deps:noble...
            Sandbox: docker (ready in 9s)
        ");
    }

    #[test]
    fn plain_docker_skipped_snapshot_phase_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "docker".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Ready {
                provider:    "docker".into(),
                duration_ms: 20,
                name:        None,
                cpu:         None,
                memory:      None,
                url:         None,
            },
        });

        insta::assert_snapshot!(rendered(&buffer), @"    Sandbox: docker (ready in 20ms)");
    }

    #[test]
    fn plain_snapshot_failure_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "docker".into(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotFailed {
                name:   "buildpack-deps:noble".into(),
                error:  "pull failed".into(),
                causes: Vec::new(),
            },
        });
        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::InitializeFailed {
                provider:    "docker".into(),
                error:       "pull failed".into(),
                causes:      Vec::new(),
                duration_ms: 900,
            },
        });

        insta::assert_snapshot!(rendered(&buffer), @r"
            Sandbox: Snapshot buildpack-deps:noble failed: pull failed
            Sandbox: docker failed: pull failed
        ");
    }

    #[test]
    fn tty_snapshot_ready_keeps_sandbox_bar_until_sandbox_ready() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "docker".into(),
            },
        });
        assert!(ui.setup.sandbox_bar.is_some());

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::SnapshotReady {
                name:        "buildpack-deps:noble".into(),
                duration_ms: 10,
            },
        });
        assert!(ui.setup.sandbox_bar.is_some());

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Ready {
                provider:    "docker".into(),
                duration_ms: 20,
                name:        None,
                cpu:         None,
                memory:      None,
                url:         None,
            },
        });
        assert!(ui.setup.sandbox_bar.is_none());
    }

    #[test]
    fn tty_sandbox_failed_finishes_sandbox_bar() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::Initializing {
                provider: "docker".into(),
            },
        });
        assert!(ui.setup.sandbox_bar.is_some());

        emit(&mut ui, Event::Sandbox {
            event: SandboxEvent::InitializeFailed {
                provider:    "docker".into(),
                error:       "pull failed".into(),
                causes:      Vec::new(),
                duration_ms: 900,
            },
        });
        assert!(ui.setup.sandbox_bar.is_none());
    }

    #[test]
    fn plain_verbose_snapshot() {
        let (mut ui, buffer) = capture_ui(true);

        emit(&mut ui, stage_started("code", "Code"));
        emit(&mut ui, Event::SandboxInitialized {
            working_directory: "/home/daytona/workspace".into(),
            provider:          SandboxProviderKind::Daytona,
            id:                "daytona:sandbox-id".into(),
            repo_cloned:       None,
            clone_origin_url:  None,
            clone_branch:      None,
            workspace_root:    None,
            repos_root:        None,
            primary_repo_path: None,
            primary_repo_link: None,
            image:             None,
            snapshot:          None,
        });
        emit(
            &mut ui,
            agent_event("code", AgentEvent::ToolCallStarted {
                tool_name:    "read_file".into(),
                tool_call_id: "tc1".into(),
                arguments:    serde_json::json!({
                    "file_path": "/home/daytona/workspace/src/main.rs"
                }),
            }),
        );
        emit(&mut ui, assistant_message("code", "gpt-5-mini"));
        emit(&mut ui, Event::EdgeSelected {
            from_node:          "code".into(),
            to_node:            "review".into(),
            label:              Some("ship".into()),
            condition:          None,
            reason:             "condition".into(),
            preferred_label:    None,
            suggested_next_ids: Vec::new(),
            stage_status:       "succeeded".into(),
            is_jump:            false,
        });
        emit(&mut ui, Event::StageRetrying {
            node_id:      "code".into(),
            name:         "Code".into(),
            index:        0,
            attempt:      2,
            max_attempts: 3,
            delay_ms:     1500,
        });
        emit(
            &mut ui,
            agent_event("code", AgentEvent::Warning {
                kind:    "context_window".into(),
                message: "high usage".into(),
                details: serde_json::json!({"usage_percent": 92}),
            }),
        );
        emit(
            &mut ui,
            agent_event("code", AgentEvent::LlmRetry {
                provider:   "openai".into(),
                model:      "gpt-5-mini".into(),
                attempt:    2,
                delay_secs: 1.5,
                phase:      fabro_types::LlmRetryPhase::Open,
                error:      fabro_llm::Error::Configuration {
                    message: "busy".into(),
                    source:  None,
                },
            }),
        );
        emit(
            &mut ui,
            agent_event("code", AgentEvent::SubAgentSpawned {
                agent_id:   "a1".into(),
                depth:      1,
                task:       "review recent changes".into(),
                generation: 1,
            }),
        );
        emit(
            &mut ui,
            agent_event("code", AgentEvent::SubAgentCompleted {
                agent_id:   "a1".into(),
                depth:      1,
                generation: 1,
                success:    true,
                turns_used: 3,
            }),
        );
        emit(
            &mut ui,
            agent_event("code", AgentEvent::SubAgentTurnStarted {
                agent_id:   "a1".into(),
                depth:      1,
                task:       "fix the review findings".into(),
                generation: 2,
            }),
        );
        emit(
            &mut ui,
            agent_event("code", AgentEvent::SubAgentCompleted {
                agent_id:   "a1".into(),
                depth:      1,
                generation: 2,
                success:    true,
                turns_used: 2,
            }),
        );
        emit(&mut ui, Event::SetupStarted { command_count: 1 });
        emit(&mut ui, Event::SetupCommandCompleted {
            command:     "bun install".into(),
            index:       0,
            exit_code:   0,
            duration_ms: 2200,
        });
        emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 });
        emit(&mut ui, stage_completed("code", "Code"));

        insta::assert_snapshot!(rendered(&buffer), @r#"
        → code → review  "ship"
        ↻ Code: retrying (attempt 2/3, delay 1s)
          ⚠ context window: 92% used
          ⚠ retry: gpt-5-mini attempt 2 (busy, delay 1s)
            ▸ subagent[a1] "review recent changes"
            ✓ subagent[a1] (3 turns)
            ↻ subagent[a1] turn 2 "fix the review findings"
            ✓ subagent[a1] (2 turns)
          ✓ [1/1] bun install  2s
        Setup: 1 command (2s)
        ✓ Code  5s  (1 turns, 0 tools, 1.5k toks)
        "#);
    }

    #[test]
    fn plain_notice_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::RunNotice {
            level:            RunNoticeLevel::Warn,
            code:             RunNoticeCode::SandboxCleanupFailed.to_string(),
            message:          "sandbox cleanup failed".into(),
            exec_output_tail: None,
        });
        emit(&mut ui, Event::PullRequestCreated {
            pr_url:      "https://github.com/fabro-sh/fabro/pull/42".into(),
            pr_number:   42,
            owner:       "fabro-sh".into(),
            repo:        "fabro".into(),
            base_branch: "main".into(),
            head_branch: "fabro/run/42".into(),
            head_sha:    Some("final-sha".to_string()),
            title:       "Ship the change".into(),
            draft:       true,
        });
        emit(&mut ui, Event::PullRequestFailed {
            creation_id: None,
            error:       "auth token expired".into(),
        });

        insta::assert_snapshot!(rendered(&buffer), @r"
            Warning: sandbox cleanup failed [sandbox_cleanup_failed]
            Draft PR: https://github.com/fabro-sh/fabro/pull/42
            PR failed: auth token expired
        ");
    }

    #[test]
    fn plain_metadata_snapshot_snapshot() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::MetadataSnapshotCompleted {
            phase:       MetadataSnapshotPhase::Checkpoint,
            branch:      "fabro/meta".into(),
            duration_ms: 2000,
            entry_count: 2,
            bytes:       42,
            commit_sha:  "abc123".into(),
        });
        emit(&mut ui, Event::MetadataSnapshotFailed {
            phase:            MetadataSnapshotPhase::Finalize,
            branch:           "fabro/meta".into(),
            duration_ms:      900,
            failure_kind:     MetadataSnapshotFailureKind::Push,
            error:            "push rejected".into(),
            causes:           Vec::new(),
            commit_sha:       Some("abc123".into()),
            entry_count:      Some(2),
            bytes:            Some(42),
            exec_output_tail: None,
        });

        insta::assert_snapshot!(rendered(&buffer), @"Warning: Metadata finalize failed: push rejected [push]");
    }

    #[test]
    fn metadata_snapshot_failure_suppresses_compat_notice_only() {
        let (mut ui, buffer) = capture_ui(false);

        emit(&mut ui, Event::MetadataSnapshotFailed {
            phase:            MetadataSnapshotPhase::Checkpoint,
            branch:           "fabro/meta".into(),
            duration_ms:      900,
            failure_kind:     MetadataSnapshotFailureKind::Write,
            error:            "write failed".into(),
            causes:           Vec::new(),
            commit_sha:       None,
            entry_count:      None,
            bytes:            None,
            exec_output_tail: None,
        });
        emit(&mut ui, Event::RunNotice {
            level:            RunNoticeLevel::Warn,
            code:             RunNoticeCode::CheckpointMetadataWriteFailed.to_string(),
            message:          "legacy metadata warning".into(),
            exec_output_tail: None,
        });
        emit(&mut ui, Event::RunNotice {
            level:            RunNoticeLevel::Warn,
            code:             RunNoticeCode::CheckpointMetadataDegraded.to_string(),
            message:          "metadata snapshots are disabled for this run".into(),
            exec_output_tail: None,
        });

        insta::assert_snapshot!(rendered(&buffer), @r"
            Warning: Metadata checkpoint failed: write failed [write]
            Warning: metadata snapshots are disabled for this run [checkpoint_metadata_degraded]
        ");
    }

    #[test]
    fn tty_parallel_branch_completion_uses_recorded_duration() {
        let mut ui = ProgressUI::new(true, false);

        emit(&mut ui, stage_started("fork1", "Fork"));
        emit(&mut ui, Event::ParallelStarted {
            node_id:      "fork1".into(),
            visit:        1,
            branch_count: 1,
        });
        emit(&mut ui, Event::ParallelBranchStarted {
            graph_visit:           None,
            resumed_from_stage_id: None,
            parallel_group_id:     StageId::new("fork1", 1),
            parallel_branch_id:    ParallelBranchId::new(StageId::new("fork1", 1), 0),
            branch:                "security".into(),
            index:                 0,
            item_label:            None,
        });
        emit(&mut ui, Event::ParallelBranchCompleted {
            parallel_group_id:  StageId::new("fork1", 1),
            parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
            branch:             "security".into(),
            index:              0,
            item_label:         None,
            duration_ms:        500,
            status:             fabro_workflow::outcome::StageOutcome::Succeeded,
        });

        let stage = &ui.stage.active_stages["fork1"];
        assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms");
    }

    #[test]
    fn tty_tool_call_completion_uses_jsonl_timestamps() {
        let mut ui = ProgressUI::new(true, false);

        let started_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.000Z")
            .unwrap()
            .with_timezone(&Utc);
        let completed_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z")
            .unwrap()
            .with_timezone(&Utc);

        let stage_started = serde_json::to_string(&to_run_event_at(
            &fixtures::RUN_1,
            &Event::StageStarted {
                graph_visit:           None,
                resumed_from_stage_id: None,
                node_id:               "code".into(),
                name:                  "Code".into(),
                index:                 0,
                handler_type:          "agent".into(),
                attempt:               1,
                max_attempts:          1,
            },
            started_ts,
            None,
        ))
        .unwrap();
        let tool_started = serde_json::to_string(&to_run_event_at(
            &fixtures::RUN_1,
            &agent_event("code", AgentEvent::ToolCallStarted {
                tool_name:    "read_file".into(),
                tool_call_id: "tc1".into(),
                arguments:    serde_json::json!({"path": "src/main.rs"}),
            }),
            started_ts,
            None,
        ))
        .unwrap();
        let tool_completed = serde_json::to_string(&to_run_event_at(
            &fixtures::RUN_1,
            &agent_event("code", AgentEvent::ToolCallCompleted {
                tool_name:             "read_file".into(),
                tool_call_id:          "tc1".into(),
                output:                serde_json::json!({"ok": true}),
                is_error:              false,
                output_bytes_observed: 11,
                output_bytes_retained: 11,
                output_bytes_omitted:  0,
            }),
            completed_ts,
            None,
        ))
        .unwrap();

        ui.handle_json_line(&stage_started);
        ui.handle_json_line(&tool_started);
        ui.handle_json_line(&tool_completed);

        let stage = &ui.stage.active_stages["code"];
        assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms");
    }
}
