//! # Contract tests for the interactive API surface
//!
//! These tests document and verify the *public behavioral contracts* of the
//! interactive path: `ask_user` matching semantics, interrupt handling,
//! timeout behavior, and event ordering guarantees.
//!
//! They are intentionally separate from the unit-level tests in
//! `tests/interactive_ops.rs` — the goal here is to serve as a
//! **compatibility specification** that guards against accidental behavioral
//! regressions across releases.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use motosan_agent_loop::{
    AgentEvent, AgentLoop, AgentOp, ChatOutput, LlmClient, LlmResponse, Message, Role, ToolCallItem,
};
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------

/// Pre-programmed mock LLM that returns responses in order.
struct MockLlm {
    responses: Mutex<Vec<LlmResponse>>,
}

impl MockLlm {
    fn new(responses: Vec<LlmResponse>) -> Self {
        Self {
            responses: Mutex::new(responses),
        }
    }
}

#[async_trait]
impl LlmClient for MockLlm {
    async fn chat(
        &self,
        _messages: &[Message],
        _tools: &[ToolDef],
    ) -> motosan_agent_loop::Result<ChatOutput> {
        let mut responses = self.responses.lock().unwrap();
        if responses.is_empty() {
            panic!("MockLlm: no more responses");
        }
        Ok(ChatOutput::new(responses.remove(0)))
    }
}

/// A tool that signals when it starts and blocks for a configurable delay.
struct DelayTool {
    name: &'static str,
    delay_ms: u64,
    started: Arc<AtomicBool>,
}

impl DelayTool {
    fn new(name: &'static str, delay_ms: u64) -> (Self, Arc<AtomicBool>) {
        let started = Arc::new(AtomicBool::new(false));
        (
            Self {
                name,
                delay_ms,
                started: started.clone(),
            },
            started,
        )
    }
}

impl Tool for DelayTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: self.name.to_string(),
            description: format!("delay tool ({}ms)", self.delay_ms),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {},
                "required": []
            }),
        }
    }

    fn call(
        &self,
        _args: serde_json::Value,
        _ctx: &ToolContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ToolResult> + Send + '_>> {
        let delay = self.delay_ms;
        let started = self.started.clone();
        Box::pin(async move {
            started.store(true, Ordering::SeqCst);
            tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
            ToolResult::text("ok")
        })
    }
}

// ===========================================================================
// Contract: ask_user matching with explicit call_id
// ===========================================================================

/// **Contract**: When an `AskUserAnswer` carries a `call_id` that matches a
/// pending `ask_user` request, the answer is delivered to that specific
/// request regardless of ordering.
#[tokio::test]
async fn contract_ask_user_answer_matches_by_call_id() {
    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![
            ToolCallItem {
                id: "ask_A".to_string(),
                name: "ask_user".to_string(),
                args: serde_json::json!({"question": "Q-A?", "options": ["1","2"]}),
            },
            ToolCallItem {
                id: "ask_B".to_string(),
                name: "ask_user".to_string(),
                args: serde_json::json!({"question": "Q-B?", "options": ["X","Y"]}),
            },
        ]),
        LlmResponse::Message("final".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .with_ask_user_timeout(std::time::Duration::from_millis(500))
        .max_iterations(3)
        .build();

    let (ops_tx, ops_rx) = tokio::sync::mpsc::channel(8);
    let ask_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let ask_count_cb = ask_count.clone();

    let result = agent
        .run_with_ops(
            &llm,
            vec![Message::user("go")],
            Some(ops_rx),
            move |event| {
                if let AgentEvent::AskUser { .. } = event {
                    let n = ask_count_cb.fetch_add(1, Ordering::SeqCst);
                    if n == 0 {
                        // Deliberately answer B first, then A — call_id matching
                        // must route each answer to the correct request.
                        ops_tx
                            .try_send(AgentOp::AskUserAnswer {
                                call_id: Some("ask_B".to_string()),
                                answer: "Y".to_string(),
                            })
                            .unwrap();
                        ops_tx
                            .try_send(AgentOp::AskUserAnswer {
                                call_id: Some("ask_A".to_string()),
                                answer: "1".to_string(),
                            })
                            .unwrap();
                    }
                }
            },
        )
        .await
        .unwrap();

    assert_eq!(result.answer, "final");
    assert_eq!(ask_count.load(Ordering::SeqCst), 2);
}

// ===========================================================================
// Contract: ask_user fallback with None call_id
// ===========================================================================

/// **Contract**: When an `AskUserAnswer` has `call_id: None`, it matches the
/// first pending `ask_user` request in FIFO order.
#[tokio::test]
async fn contract_ask_user_none_call_id_matches_any_pending() {
    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "call_42".to_string(),
            name: "ask_user".to_string(),
            args: serde_json::json!({"question": "Pick?", "options": ["A","B"]}),
        }]),
        LlmResponse::Message("received".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .with_ask_user()
        .max_iterations(3)
        .build();

    let (ops_tx, ops_rx) = tokio::sync::mpsc::channel(8);
    let captured_call_id = Arc::new(Mutex::new(String::new()));
    let captured_clone = captured_call_id.clone();

    let result = agent
        .run_with_ops(
            &llm,
            vec![Message::user("start")],
            Some(ops_rx),
            move |event| {
                if let AgentEvent::AskUser { call_id, .. } = event {
                    *captured_clone.lock().unwrap() = call_id;
                    // Answer with None call_id — must still resolve.
                    ops_tx
                        .try_send(AgentOp::AskUserAnswer {
                            call_id: None,
                            answer: "B".to_string(),
                        })
                        .unwrap();
                }
            },
        )
        .await
        .unwrap();

    assert_eq!(result.answer, "received");
    // The event must have carried the original call_id.
    assert_eq!(*captured_call_id.lock().unwrap(), "call_42");
}

// ===========================================================================
// Contract: Interrupt causes immediate exit with "(interrupted)" answer
// ===========================================================================

/// **Contract**: Sending `AgentOp::Interrupt` causes the loop to exit at the
/// next safe checkpoint and return an answer of `"(interrupted)"`.  An
/// `AgentEvent::Interrupted` event is emitted.
#[tokio::test]
async fn contract_interrupt_exits_with_interrupted_answer() {
    let llm = MockLlm::new(vec![LlmResponse::Message("should-never-reach".to_string())]);

    let agent = AgentLoop::builder().max_iterations(5).build();
    let (ops_tx, ops_rx) = tokio::sync::mpsc::channel(8);

    // Pre-load the interrupt before run starts.
    ops_tx.send(AgentOp::Interrupt).await.unwrap();

    let events = Arc::new(Mutex::new(Vec::new()));
    let events_cb = events.clone();

    let result = agent
        .run_with_ops(
            &llm,
            vec![Message::user("hi")],
            Some(ops_rx),
            move |event| {
                events_cb.lock().unwrap().push(format!("{event:?}"));
            },
        )
        .await
        .unwrap();

    assert_eq!(result.answer, "(interrupted)");
    let seen = events.lock().unwrap();
    assert!(
        seen.iter().any(|e| e.contains("Interrupted")),
        "expected Interrupted event, got: {:?}",
        *seen
    );
}

/// **Contract**: Sending `AgentOp::Interrupt` while the loop is blocked
/// waiting for an `ask_user` answer causes it to exit immediately rather
/// than waiting for the timeout.
#[tokio::test]
async fn contract_interrupt_during_ask_user_exits_immediately() {
    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "call_1".to_string(),
            name: "ask_user".to_string(),
            args: serde_json::json!({"question": "wait?", "options": ["yes"]}),
        }]),
        LlmResponse::Message("unreachable".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .with_ask_user_timeout(std::time::Duration::from_secs(10))
        .max_iterations(5)
        .build();

    let (ops_tx, ops_rx) = tokio::sync::mpsc::channel(8);
    let events = Arc::new(Mutex::new(Vec::<String>::new()));
    let events_cb = events.clone();

    let start = std::time::Instant::now();
    let result = agent
        .run_with_ops(
            &llm,
            vec![Message::user("go")],
            Some(ops_rx),
            move |event| match event {
                AgentEvent::AskUser { .. } => {
                    let tx = ops_tx.clone();
                    tokio::spawn(async move {
                        tx.send(AgentOp::Interrupt).await.unwrap();
                    });
                }
                AgentEvent::Interrupted => {
                    events_cb.lock().unwrap().push("interrupted".to_string());
                }
                AgentEvent::AskUserTimeout { .. } => {
                    events_cb.lock().unwrap().push("timeout".to_string());
                }
                _ => {}
            },
        )
        .await
        .unwrap();
    let elapsed = start.elapsed();

    assert_eq!(result.answer, "(interrupted)");
    // Must complete well before the 10s timeout.
    assert!(
        elapsed < std::time::Duration::from_secs(2),
        "interrupt should exit immediately, took {elapsed:?}"
    );
    let seen = events.lock().unwrap();
    assert!(seen.contains(&"interrupted".to_string()));
    assert!(
        !seen.contains(&"timeout".to_string()),
        "should not emit timeout when interrupted"
    );
}

// ===========================================================================
// Contract: ask_user timeout emits AskUserTimeout event
// ===========================================================================

/// **Contract**: When no `AskUserAnswer` arrives within the configured
/// timeout, an `AskUserTimeout` event is emitted and the tool returns an
/// empty string.
#[tokio::test]
async fn contract_ask_user_timeout_emits_event() {
    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "call_t".to_string(),
            name: "ask_user".to_string(),
            args: serde_json::json!({"question": "hurry?", "options": []}),
        }]),
        LlmResponse::Message("timed-out".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .with_ask_user_timeout(std::time::Duration::from_millis(50))
        .max_iterations(3)
        .build();

    // No ops channel answer will be sent — trigger timeout.
    let (_ops_tx, ops_rx) = tokio::sync::mpsc::channel::<AgentOp>(8);
    let timeout_events = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
    let timeout_cb = timeout_events.clone();

    let result = agent
        .run_with_ops(
            &llm,
            vec![Message::user("start")],
            Some(ops_rx),
            move |event| {
                if let AgentEvent::AskUserTimeout { call_id, question } = event {
                    timeout_cb.lock().unwrap().push((call_id, question));
                }
            },
        )
        .await
        .unwrap();

    assert_eq!(result.answer, "timed-out");
    let timeouts = timeout_events.lock().unwrap();
    assert_eq!(timeouts.len(), 1);
    assert_eq!(timeouts[0].0, "call_t");
    assert_eq!(timeouts[0].1, "hurry?");
}

/// **Contract**: When no ops receiver is provided at all (None), `ask_user`
/// immediately times out.
#[tokio::test]
async fn contract_ask_user_no_ops_channel_times_out_immediately() {
    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "call_no_rx".to_string(),
            name: "ask_user".to_string(),
            args: serde_json::json!({"question": "anyone?", "options": []}),
        }]),
        LlmResponse::Message("nobody-answered".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .with_ask_user()
        .max_iterations(3)
        .build();

    let timeout_seen = Arc::new(AtomicBool::new(false));
    let timeout_cb = timeout_seen.clone();

    let start = std::time::Instant::now();
    let result = agent
        .run_with_ops(&llm, vec![Message::user("start")], None, move |event| {
            if let AgentEvent::AskUserTimeout { .. } = event {
                timeout_cb.store(true, Ordering::SeqCst);
            }
        })
        .await
        .unwrap();
    let elapsed = start.elapsed();

    assert_eq!(result.answer, "nobody-answered");
    assert!(timeout_seen.load(Ordering::SeqCst));
    // Should be nearly instant since there is no channel to wait on.
    assert!(
        elapsed < std::time::Duration::from_secs(1),
        "no-ops-channel timeout should be immediate, took {elapsed:?}"
    );
}

// ===========================================================================
// Contract: Event ordering guarantees
// ===========================================================================

/// **Contract**: Events for a single tool call are emitted in the guaranteed
/// order: `IterationStarted` -> `ToolStarted` -> `ToolCompleted` ->
/// `TextChunk`/`TextDone`.
///
/// Within a batch of tool calls, `ToolStarted` for each tool precedes its
/// own `ToolCompleted`, but interleaving between different tools is allowed
/// (they run in parallel).
#[tokio::test]
async fn contract_event_ordering_iteration_tool_text() {
    let (delay_tool, _) = DelayTool::new("compute", 20);

    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "c1".to_string(),
            name: "compute".to_string(),
            args: serde_json::json!({}),
        }]),
        LlmResponse::Message("result".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .tool(Arc::new(delay_tool))
        .max_iterations(5)
        .build();

    let events = Arc::new(Mutex::new(Vec::<String>::new()));
    let events_cb = events.clone();

    let result = agent
        .run(&llm, vec![Message::user("go")], move |event| {
            let tag = match &event {
                AgentEvent::IterationStarted(n) => format!("iter:{n}"),
                AgentEvent::ToolStarted { name } => format!("tool_start:{name}"),
                AgentEvent::ToolCompleted { name, .. } => format!("tool_done:{name}"),
                AgentEvent::TextChunk(t) => format!("text_chunk:{t}"),
                AgentEvent::TextDone(t) => format!("text_done:{t}"),
                other => format!("other:{other:?}"),
            };
            events_cb.lock().unwrap().push(tag);
        })
        .await
        .unwrap();

    assert_eq!(result.answer, "result");

    let captured = events.lock().unwrap();
    let idx = |tag: &str| {
        captured
            .iter()
            .position(|e| e == tag)
            .unwrap_or_else(|| panic!("missing event: {tag}, got: {captured:?}"))
    };

    // Iteration 1: tool call phase.
    let iter1 = idx("iter:1");
    let tool_start = idx("tool_start:compute");
    let tool_done = idx("tool_done:compute");

    // Iteration 2: final message phase.
    let iter2 = idx("iter:2");
    let text_chunk = idx("text_chunk:result");

    // Guaranteed ordering.
    assert!(
        iter1 < tool_start,
        "IterationStarted must precede ToolStarted"
    );
    assert!(
        tool_start < tool_done,
        "ToolStarted must precede ToolCompleted"
    );
    assert!(
        tool_done < iter2,
        "ToolCompleted must precede next IterationStarted"
    );
    assert!(
        iter2 < text_chunk,
        "IterationStarted must precede TextChunk"
    );
}

/// **Contract**: Parallel tool calls each have their own `ToolStarted` ->
/// `ToolCompleted` ordering, and all complete before the next
/// `IterationStarted`.
#[tokio::test]
async fn contract_parallel_tools_ordering() {
    let (tool_a, _) = DelayTool::new("alpha", 30);
    let (tool_b, _) = DelayTool::new("beta", 30);

    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![
            ToolCallItem {
                id: "c_a".to_string(),
                name: "alpha".to_string(),
                args: serde_json::json!({}),
            },
            ToolCallItem {
                id: "c_b".to_string(),
                name: "beta".to_string(),
                args: serde_json::json!({}),
            },
        ]),
        LlmResponse::Message("both-done".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .tool(Arc::new(tool_a))
        .tool(Arc::new(tool_b))
        .max_iterations(5)
        .build();

    let events = Arc::new(Mutex::new(Vec::<String>::new()));
    let events_cb = events.clone();

    let result = agent
        .run(&llm, vec![Message::user("go")], move |event| {
            let tag = match &event {
                AgentEvent::IterationStarted(n) => format!("iter:{n}"),
                AgentEvent::ToolStarted { name } => format!("tool_start:{name}"),
                AgentEvent::ToolCompleted { name, .. } => format!("tool_done:{name}"),
                AgentEvent::TextChunk(t) => format!("text:{t}"),
                _ => return,
            };
            events_cb.lock().unwrap().push(tag);
        })
        .await
        .unwrap();

    assert_eq!(result.answer, "both-done");

    let captured = events.lock().unwrap();
    let idx = |tag: &str| {
        captured
            .iter()
            .position(|e| e == tag)
            .unwrap_or_else(|| panic!("missing event: {tag}, got: {captured:?}"))
    };

    let iter1 = idx("iter:1");
    let a_start = idx("tool_start:alpha");
    let a_done = idx("tool_done:alpha");
    let b_start = idx("tool_start:beta");
    let b_done = idx("tool_done:beta");
    let iter2 = idx("iter:2");

    // Per-tool ordering.
    assert!(a_start < a_done, "alpha: ToolStarted before ToolCompleted");
    assert!(b_start < b_done, "beta: ToolStarted before ToolCompleted");

    // Both tools within first iteration.
    assert!(iter1 < a_start);
    assert!(iter1 < b_start);
    assert!(a_done < iter2);
    assert!(b_done < iter2);
}

// ===========================================================================
// Contract: Tool result messages carry correct tool_call_id
// ===========================================================================

/// **Contract**: Tool result messages in the conversation carry the correct
/// `tool_call_id` matching the original tool call request, ensuring the LLM
/// can correlate results.
#[tokio::test]
async fn contract_tool_result_messages_carry_correct_call_id() {
    let (tool, _) = DelayTool::new("echo", 5);

    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "unique_id_42".to_string(),
            name: "echo".to_string(),
            args: serde_json::json!({}),
        }]),
        LlmResponse::Message("done".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .tool(Arc::new(tool))
        .max_iterations(3)
        .build();

    let result = agent
        .run(&llm, vec![Message::user("go")], |_| {})
        .await
        .unwrap();

    assert_eq!(result.answer, "done");

    // Find the Tool role message and verify its tool_call_id.
    let tool_msg = result
        .messages
        .iter()
        .find(|m| m.role == Role::Tool)
        .expect("expected a Tool role message in result.messages");
    assert_eq!(
        tool_msg.tool_call_id.as_deref(),
        Some("unique_id_42"),
        "tool result must carry the original tool_call_id"
    );
}

// ===========================================================================
// Contract: AgentResult fields are populated correctly
// ===========================================================================

/// **Contract**: `AgentResult` must contain the correct `answer`,
/// `iterations` count, `tool_calls` list, and `messages` history after a
/// successful run.
#[tokio::test]
async fn contract_agent_result_fields_populated() {
    let (tool, _) = DelayTool::new("calc", 5);

    let llm = MockLlm::new(vec![
        LlmResponse::ToolCalls(vec![ToolCallItem {
            id: "c1".to_string(),
            name: "calc".to_string(),
            args: serde_json::json!({"x": 1}),
        }]),
        LlmResponse::Message("42".to_string()),
    ]);

    let agent = AgentLoop::builder()
        .tool(Arc::new(tool))
        .max_iterations(5)
        .build();

    let result = agent
        .run(&llm, vec![Message::user("compute")], |_| {})
        .await
        .unwrap();

    assert_eq!(result.answer, "42");
    assert_eq!(
        result.iterations, 2,
        "1 tool call iteration + 1 final message"
    );
    assert_eq!(result.tool_calls.len(), 1);
    assert_eq!(result.tool_calls[0].0, "calc");
    assert!(
        !result.messages.is_empty(),
        "messages must contain conversation history"
    );
    // Must contain user, assistant (tool call), tool result, assistant (final).
    assert!(result.messages.iter().any(|m| m.role == Role::User));
    assert!(result.messages.iter().any(|m| m.role == Role::Tool));
    assert!(result
        .messages
        .iter()
        .any(|m| m.role == Role::Assistant && m.content == "42"));
}
