//! Pure FSM transition tests. No IO, no async.

use effects::{Effect, ExitAction};
use events::{Event, PermissionChoice, PermissionResponse};
use rstest::{fixture, rstest};
use serde_json::json;

use super::tools::{InterruptReason, ToolPreviewData};
use super::*;

#[fixture]
fn new_fsm() -> AgentFsm {
    AgentFsm::new(vec!["client_v1_read_file".to_string()], "test-inv".to_string())
}

// ============================================================================
// Idle → Turn
// ============================================================================

#[rstest]
fn user_submit_starts_turn(#[from(new_fsm)] mut fsm: AgentFsm) {
    let effects = fsm.handle(Event::UserSubmit("hello".into()));

    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));
    assert_eq!(effects.len(), 1);
    assert!(matches!(effects[0], Effect::StartStream { .. }));
    // User message was pushed to events
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::UserMessage { content } if content == "hello"
    )));
}

#[rstest]
fn stream_started_transitions_to_streaming(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));

    let effects = fsm.handle(Event::StreamStarted);

    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Streaming { status: None }
    }));
    assert!(effects.is_empty());
}

#[rstest]
fn stream_chunk_accumulates_text(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);

    fsm.handle(Event::StreamChunk("Hello ".into()));
    fsm.handle(Event::StreamChunk("world!".into()));

    assert_eq!(fsm.ctx.current_response, "Hello world!");
}

#[rstest]
fn stream_done_without_tools_goes_idle(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamChunk("Hi there!".into()));

    let effects = fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert_eq!(fsm.ctx.session_id, Some("s1".to_string()));
    assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
    // Text was committed to events
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::Text { content } if content == "Hi there!"
    )));
}

#[rstest]
fn stream_done_without_tools_emits_turn_ended(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);

    let effects = fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    assert!(effects.iter().any(|e| matches!(e, Effect::TurnEnded)));
}

#[rstest]
fn suggest_command_emits_turn_ended(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("list files".into()));
    fsm.handle(Event::StreamStarted);

    let effects = fsm.handle(Event::SuggestCommand {
        id: "t1".into(),
        input: json!({"command": "ls"}),
    });

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert!(effects.iter().any(|e| matches!(e, Effect::TurnEnded)));
}

#[rstest]
fn turn_ended_waits_for_the_continuation_stream(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    // Tool completes → continuation starts; the turn is not over yet.
    let effects = fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("contents".into()),
        preview: None,
    });
    assert!(!effects.iter().any(|e| matches!(e, Effect::TurnEnded)));

    // The continuation stream finishing ends the turn.
    fsm.handle(Event::StreamStarted);
    let effects = fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    assert!(effects.iter().any(|e| matches!(e, Effect::TurnEnded)));
}

// ============================================================================
// Tool lifecycle
// ============================================================================

#[rstest]
fn stream_tool_call_tracks_tool_and_emits_check_permission(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read a file".into()));
    fsm.handle(Event::StreamStarted);

    let effects = fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });

    assert!(fsm.ctx.tools.get("t1").is_some());
    assert_eq!(effects.len(), 1);
    assert!(matches!(effects[0], Effect::CheckPermission { .. }));
}

#[rstest]
fn atuin_output_call_emits_command_lookup_and_stores_result() {
    let mut fsm = AgentFsm::new(vec!["client_v1_atuin_output".to_string()], "test-inv".to_string());
    fsm.handle(Event::UserSubmit("show output".into()));
    fsm.handle(Event::StreamStarted);

    let effects = fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "atuin_output".into(),
        input: json!({"history_id": "018f011c-9a0a-7000-8000-000000000001"}),
    });

    // The command lookup runs alongside the permission check.
    assert!(matches!(
        effects[0],
        Effect::ResolveOutputCommand { ref tool_id, .. } if tool_id == "t1"
    ));
    assert!(matches!(effects[1], Effect::CheckPermission { .. }));

    let effects = fsm.handle(Event::OutputCommandResolved {
        tool_id: "t1".into(),
        command: Some("cargo test".into()),
    });

    assert!(effects.is_empty());
    let crate::tools::ClientToolCall::AtuinOutput(call) = &fsm.ctx.tools.get("t1").unwrap().tool
    else {
        panic!("expected AtuinOutput tool");
    };
    assert_eq!(call.command.as_deref(), Some("cargo test"));
}

#[rstest]
#[case::allowed(PermissionResponse::Allowed, ToolState::Executing, true)]
#[case::ask(PermissionResponse::Ask, ToolState::AwaitingPermission, false)]
fn permission_resolution(
    #[from(new_fsm)] mut fsm: AgentFsm,
    #[case] response: PermissionResponse,
    #[case] expected_state: ToolState,
    #[case] expect_execute: bool,
) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });

    let effects = fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response,
    });

    assert_eq!(fsm.ctx.tools.get("t1").unwrap().state, expected_state);
    if expect_execute {
        assert!(matches!(effects[0], Effect::ExecuteTool { .. }));
    } else {
        assert!(effects.is_empty());
    }
}

#[rstest]
fn tool_done_after_stream_done_continues_conversation(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    // Now in Turn { Done } with one tool Executing
    let effects = fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("file contents".into()),
        preview: None,
    });

    // Turn complete → continuation
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));
    assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}

#[rstest]
fn continuation_turn_without_new_tools_goes_idle(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    // Tool completes → continuation starts
    fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("contents".into()),
        preview: None,
    });
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));

    // Continuation stream: text only, no new tools
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamChunk("Here's the file.".into()));
    let effects = fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });

    // Should go Idle, NOT start another continuation
    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
    assert!(!effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}

#[rstest]
fn tool_done_before_stream_done_stays_in_turn(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    // Tool completes but stream hasn't sent Done yet
    let effects = fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("contents".into()),
        preview: None,
    });

    // Still in Turn — stream phase is Streaming, not Done
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Streaming { .. }
    }));
    assert!(effects.is_empty());
}

// ============================================================================
// Cancel
// ============================================================================

#[rstest]
fn cancel_during_streaming_goes_idle(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamChunk("partial text".into()));

    let effects = fsm.handle(Event::Cancel);

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert!(effects.iter().any(|e| matches!(e, Effect::AbortStream)));
    assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
    assert!(
        !effects.iter().any(|e| matches!(e, Effect::TurnEnded)),
        "cancel is not a clean turn end"
    );
    // Partial text committed with cancel suffix
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::Text { content } if content.contains("[User cancelled")
    )));
}

#[rstest]
fn stale_permission_resolved_after_cancel_is_ignored(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    // Tool is in CheckingPermission, cancel happens before permission resolves
    fsm.handle(Event::Cancel);
    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });

    // Stale permission result arrives — tool is already Completed (cancelled)
    let effects = fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    // Should NOT emit ExecuteTool — the tool was cancelled
    assert!(effects.is_empty());
}

#[rstest]
fn cancel_during_turn_with_pending_tools(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    // Tool is Executing, stream is Done

    let effects = fsm.handle(Event::Cancel);

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert!(effects.iter().any(|e| matches!(e, Effect::AbortTool { .. })));
    // Error ToolResult injected
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "t1"
    )));
    // SystemContext about cancellation
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::SystemContext { content } if content.contains("cancelled")
    )));
}

#[rstest]
fn stale_tool_result_after_cancel_is_ignored(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::Cancel);

    // Stale event arrives
    let effects = fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("contents".into()),
        preview: None,
    });

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
    assert!(effects.is_empty());
}

// ============================================================================
// Confirmation
// ============================================================================

#[rstest]
fn dangerous_command_enters_confirmation(#[from(new_fsm)] mut fsm: AgentFsm) {
    // Simulate a dangerous command in history
    fsm.ctx.events.push(ConversationEvent::ToolCall {
        id: "sc1".into(),
        name: "suggest_command".into(),
        input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
    });

    let effects = fsm.handle(Event::ExecuteCommand);

    assert!(matches!(fsm.state, AgentState::Idle {
        confirmation: Some(_)
    }));
    assert!(effects.iter().any(|e| matches!(e, Effect::ScheduleTimeout { .. })));
}

#[rstest]
fn second_execute_confirms_and_exits(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.ctx.events.push(ConversationEvent::ToolCall {
        id: "sc1".into(),
        name: "suggest_command".into(),
        input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
    });
    fsm.handle(Event::ExecuteCommand);

    let effects = fsm.handle(Event::ExecuteCommand);

    assert!(effects.iter().any(|e| matches!(
        e,
        Effect::ExitApp(ExitAction::Execute(cmd)) if cmd == "rm -rf /"
    )));
}

#[rstest]
fn confirmation_timeout_clears_confirmation(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.ctx.events.push(ConversationEvent::ToolCall {
        id: "sc1".into(),
        name: "suggest_command".into(),
        input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
    });
    fsm.handle(Event::ExecuteCommand);
    let timeout_id = match &fsm.state {
        AgentState::Idle {
            confirmation: Some(c),
        } => c.timeout_id,
        _ => panic!("expected confirmation"),
    };

    fsm.handle(Event::ConfirmationTimeout { timeout_id });

    assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
}

// ============================================================================
// Error / Retry
// ============================================================================

#[rstest]
fn stream_error_goes_to_error_state(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);

    fsm.handle(Event::StreamError("network error".into()));

    assert_eq!(fsm.state, AgentState::Error("network error".to_string()));
}

#[rstest]
fn retry_from_error_starts_new_stream(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamError("fail".into()));

    let effects = fsm.handle(Event::Retry);

    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));
    assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}

// ============================================================================
// Permission choices
// ============================================================================

#[rstest]
fn permission_deny_completes_turn_and_continues(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Ask,
    });

    let effects = fsm.handle(Event::PermissionUserChoice {
        tool_id: "t1".into(),
        choice: PermissionChoice::Deny,
    });

    // Turn should complete since all tools resolved and stream is done
    // → continuation needed (there was a tool result to send back)
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));
    assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
    // Error result was injected
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "t1"
    )));
}

// ============================================================================
// Unknown tools
// ============================================================================

#[rstest]
fn unknown_tool_call_gets_error_result(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "execute_socket_command".into(),
        input: json!({"command": "ls"}),
    });
    let effects = fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });

    // Both the call and an error result are recorded, so the history sent
    // upstream never contains a dangling tool_use.
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::ToolCall { id, name, .. } if id == "t1" && name == "execute_socket_command"
    )));
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "t1"
    )));

    // The tool never enters the execution lifecycle, but it resolves the
    // turn like a completed tool: the continuation starts immediately so
    // the model sees the error and can retry.
    assert!(fsm.ctx.tools.get("t1").is_none());
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Connecting
    }));
    assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}

#[rstest]
fn unknown_tool_alongside_real_tool_waits_for_both(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("hello".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });
    fsm.handle(Event::StreamToolCall {
        id: "t2".into(),
        name: "execute_socket_command".into(),
        input: json!({"command": "ls"}),
    });
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::StreamDone {
        session_id: "".into(),
    });

    // Still waiting on the real tool — the pre-answered unknown call must
    // not complete the turn early.
    assert!(matches!(fsm.state, AgentState::Turn {
        stream: StreamPhase::Done
    }));

    let effects = fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("contents".into()),
        preview: None,
    });

    // Both results now ride the same continuation.
    assert!(effects.iter().any(|e| matches!(e, Effect::StartStream { .. })));
}

// ============================================================================
// Shell execution timeouts
// ============================================================================

#[fixture]
fn fsm_with_shell() -> AgentFsm {
    AgentFsm::new(
        vec!["client_v1_read_file".to_string(), "client_v1_execute_shell_command".to_string()],
        "test-inv".to_string(),
    )
}

fn shell_tool_call_event(id: &str) -> Event {
    Event::StreamToolCall {
        id: id.into(),
        name: "execute_shell_command".into(),
        input: json!({
            "command": "sleep 999",
            "shell": "bash",
            "timeout": 60,
            "description": "test"
        }),
    }
}

#[rstest]
fn shell_tool_schedules_execution_timeout(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run something".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));

    let effects = fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    // Should have ExecuteTool + ScheduleTimeout
    assert!(effects.iter().any(|e| matches!(e, Effect::ExecuteTool { .. })));
    assert!(effects.iter().any(|e| matches!(
        e,
        Effect::ScheduleTimeout { kind: effects::TimeoutKind::ToolExecution { tool_id }, .. }
            if tool_id == "t1"
    )));
    assert!(!fsm.ctx.tool_timeout_ids.is_empty());
}

#[rstest]
fn read_tool_does_not_schedule_timeout(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("read".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "read_file".into(),
        input: json!({"file_path": "/tmp/test.txt"}),
    });

    let effects = fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    assert!(effects.iter().any(|e| matches!(e, Effect::ExecuteTool { .. })));
    assert!(!effects.iter().any(|e| matches!(e, Effect::ScheduleTimeout { .. })));
    assert!(fsm.ctx.tool_timeout_ids.is_empty());
}

#[rstest]
fn tool_completion_clears_timeout_mapping(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    assert!(!fsm.ctx.tool_timeout_ids.is_empty());

    // Tool completes naturally
    fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("done".into()),
        preview: None,
    });

    assert!(fsm.ctx.tool_timeout_ids.is_empty());
}

#[rstest]
fn stale_timeout_after_natural_completion_is_ignored(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    // Tool completes naturally
    fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Success("done".into()),
        preview: None,
    });

    // Stale timeout fires — should be no-op
    let effects = fsm.handle(Event::ToolExecutionTimeout {
        timeout_id: 0,
        tool_id: "t1".into(),
    });

    assert!(effects.is_empty());
}

#[rstest]
fn timeout_fires_before_completion_emits_abort(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    // Timeout fires while tool is still executing
    let effects = fsm.handle(Event::ToolExecutionTimeout {
        timeout_id: 0,
        tool_id: "t1".into(),
    });

    assert_eq!(effects.len(), 1);
    assert!(matches!(
        effects[0],
        Effect::AbortTool { ref tool_id } if tool_id == "t1"
    ));
    // Timeout mapping cleaned up
    assert!(fsm.ctx.tool_timeout_ids.is_empty());
}

#[rstest]
fn timeout_respects_llm_specified_duration(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);

    // Tool call with timeout: 120
    fsm.handle(Event::StreamToolCall {
        id: "t1".into(),
        name: "execute_shell_command".into(),
        input: json!({
            "command": "cargo build",
            "shell": "bash",
            "timeout": 120,
            "description": "build"
        }),
    });

    let effects = fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    let timeout_effect = effects.iter().find(|e| matches!(e, Effect::ScheduleTimeout { .. }));
    assert!(matches!(
        timeout_effect,
        Some(Effect::ScheduleTimeout { duration, .. }) if *duration == std::time::Duration::from_secs(120)
    ));
}

#[rstest]
fn cancel_clears_timeout_mappings(#[from(fsm_with_shell)] mut fsm: AgentFsm) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    assert!(!fsm.ctx.tool_timeout_ids.is_empty());

    fsm.handle(Event::Cancel);

    assert!(fsm.ctx.tool_timeout_ids.is_empty());
}

#[rstest]
#[case::timeout(
    Event::ToolExecutionTimeout { timeout_id: 0, tool_id: "t1".into() },
    InterruptReason::Timeout(60),
    "[Timed out after 60s]",
    Some("[Interrupted by user]")
)]
#[case::user_interrupt(Event::InterruptTools, InterruptReason::User, "[Interrupted by user]", None)]
fn interrupt_propagates_reason_to_preview_and_llm(
    #[from(fsm_with_shell)] mut fsm: AgentFsm,
    #[case] trigger: Event,
    #[case] expected_reason: InterruptReason,
    #[case] expected_msg: &str,
    #[case] absent_msg: Option<&str>,
) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });
    fsm.handle(Event::StreamDone {
        session_id: "s1".into(),
    });

    // Trigger the interrupt (timeout fires, or the user interrupts).
    fsm.handle(trigger);

    // Tool completes after abort (interrupted: true from execute_shell_command_streaming)
    fsm.handle(Event::ToolExecutionDone {
        tool_id: "t1".into(),
        outcome: crate::tools::ToolOutcome::Structured {
            stdout: "partial output".into(),
            stderr: String::new(),
            exit_code: None,
            duration_ms: 60000,
            interrupted: true,
        },
        preview: Some(ToolPreviewData::Shell {
            lines: vec!["partial output".into()],
            exit_code: None,
            interrupted: None, // FSM overrides this with the reason
        }),
    });

    // Preview should carry the expected reason
    let tracked = fsm.ctx.tools.get("t1").unwrap();
    let preview = tracked.shell_preview().unwrap();
    assert_eq!(preview.interrupted, Some(expected_reason));

    // LLM content should carry the expected message
    let tool_result = fsm.ctx.events.iter().find(
        |e| matches!(e, ConversationEvent::ToolResult { tool_use_id, .. } if tool_use_id == "t1"),
    );
    if let Some(ConversationEvent::ToolResult { content, .. }) = tool_result {
        assert!(
            content.contains(expected_msg),
            "Expected message {expected_msg:?}, got: {content}"
        );
        if let Some(absent) = absent_msg {
            assert!(!content.contains(absent));
        }
    } else {
        panic!("No ToolResult found for t1");
    }
}

#[rstest]
fn user_interrupt_clears_timeout_mappings_for_aborted_tools(
    #[from(fsm_with_shell)] mut fsm: AgentFsm,
) {
    fsm.handle(Event::UserSubmit("run".into()));
    fsm.handle(Event::StreamStarted);
    fsm.handle(shell_tool_call_event("t1"));
    fsm.handle(Event::PermissionResolved {
        tool_id: "t1".into(),
        response: PermissionResponse::Allowed,
    });

    assert!(!fsm.ctx.tool_timeout_ids.is_empty());

    fsm.handle(Event::InterruptTools);

    assert!(fsm.ctx.tool_timeout_ids.is_empty());
}

// ============================================================================
// Model picker
// ============================================================================

fn model_list() -> crate::models::ModelList {
    crate::models::ModelList {
        default: "fast".to_string(),
        models: vec![
            crate::models::ModelInfo {
                alias: "fast".to_string(),
                name: "Comet".to_string(),
                description: "Fastest model".to_string(),
            },
            crate::models::ModelInfo {
                alias: "deep".to_string(),
                name: "Constellation".to_string(),
                description: "Deeper reasoning".to_string(),
            },
        ],
    }
}

#[rstest]
fn open_model_picker_fetches_when_uncached(#[from(new_fsm)] mut fsm: AgentFsm) {
    let effects = fsm.handle(Event::OpenModelPicker);

    assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Loading));
    assert!(matches!(effects[..], [Effect::FetchModels]));
}

#[rstest]
fn open_model_picker_reuses_cache_without_fetching(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.ctx.models_cache = Some(model_list());

    let effects = fsm.handle(Event::OpenModelPicker);

    assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Ready(model_list())));
    assert!(effects.is_empty());
}

#[rstest]
fn model_list_loaded_populates_picker_and_cache(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::OpenModelPicker);

    let effects = fsm.handle(Event::ModelListLoaded(Ok(model_list())));

    assert_eq!(fsm.ctx.models_cache, Some(model_list()));
    assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Ready(model_list())));
    assert!(effects.is_empty());
}

#[rstest]
fn model_list_loaded_after_dismissal_caches_but_keeps_picker_closed(
    #[from(new_fsm)] mut fsm: AgentFsm,
) {
    fsm.handle(Event::OpenModelPicker);
    fsm.handle(Event::Cancel); // dismiss while loading

    fsm.handle(Event::ModelListLoaded(Ok(model_list())));

    assert_eq!(fsm.ctx.models_cache, Some(model_list()));
    assert_eq!(fsm.ctx.model_picker, None);
}

#[rstest]
fn model_list_load_failure_closes_picker_with_message(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::OpenModelPicker);

    fsm.handle(Event::ModelListLoaded(Err("boom".to_string())));

    assert_eq!(fsm.ctx.model_picker, None);
    assert!(fsm.ctx.models_cache.is_none());
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::OutOfBandOutput { content, .. } if content.contains("boom")
    )));
}

#[rstest]
fn model_selected_sets_model_and_persists(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::OpenModelPicker);
    fsm.handle(Event::ModelListLoaded(Ok(model_list())));

    let effects = fsm.handle(Event::ModelSelected("deep".to_string()));

    assert_eq!(fsm.ctx.model, Some("deep".to_string()));
    assert_eq!(fsm.ctx.model_picker, None);
    assert!(matches!(
        &effects[..],
        [Effect::SaveModelSelection { alias }] if alias == "deep"
    ));
    // Confirmation names the model, not just the alias
    assert!(fsm.ctx.events.iter().any(|e| matches!(
        e,
        ConversationEvent::OutOfBandOutput { content, .. } if content.contains("Constellation")
    )));
}

#[rstest]
fn cancel_closes_picker_instead_of_exiting(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::OpenModelPicker);

    let effects = fsm.handle(Event::Cancel);

    assert_eq!(fsm.ctx.model_picker, None);
    assert!(effects.is_empty());

    // A second Cancel with no picker open exits as usual
    let effects = fsm.handle(Event::Cancel);
    assert!(matches!(effects[..], [Effect::ExitApp(ExitAction::Cancel)]));
}

#[rstest]
fn user_submit_dismisses_loading_picker(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.handle(Event::OpenModelPicker);

    fsm.handle(Event::UserSubmit("hello".into()));

    assert_eq!(fsm.ctx.model_picker, None);
}

#[rstest]
fn selected_model_survives_new_session(#[from(new_fsm)] mut fsm: AgentFsm) {
    fsm.ctx.models_cache = Some(model_list());
    fsm.handle(Event::OpenModelPicker);
    fsm.handle(Event::ModelSelected("deep".to_string()));

    fsm.handle(Event::NewSession);

    assert_eq!(fsm.ctx.model, Some("deep".to_string()));
    assert_eq!(fsm.ctx.models_cache, Some(model_list()));
}
