//! Limit-enforcement, kill-respawn, per-session counting, and retry tests
//! for the watchdog (bugs 624, 646, 650).

use super::super::super::super::{AgentPool, composite_key};
use super::{
    write_fake_budget_session_log, write_fake_mixed_session_log, write_fake_session_log,
    write_project_config,
};
use crate::agents::{AgentEvent, AgentStatus, TerminationReason};

// ── Limit enforcement integration tests (bug 624) ────────────────────────

#[tokio::test]
async fn watchdog_terminates_agent_exceeding_turn_limit() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 10
"#,
    );

    // Write 12 turns in the current session (exceeds limit of 10).
    write_fake_session_log(root, "story_a", "coder-1", "sess-current", 12);

    let pool = AgentPool::new_test(3001);
    let tx = pool.inject_test_agent_with_session(
        "story_a",
        "coder-1",
        AgentStatus::Running,
        "sess-current",
    );
    let mut rx = tx.subscribe();

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(found >= 1, "watchdog should detect the over-limit agent");

    // Agent should now be Failed with TurnLimit reason.
    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key("story_a", "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(agent.status, AgentStatus::Failed);
        assert_eq!(
            agent.termination_reason,
            Some(TerminationReason::TurnLimit),
            "termination reason must be TurnLimit"
        );
    }

    let event = rx.try_recv().expect("watchdog must emit an Error event");
    assert!(
        matches!(event, AgentEvent::Error { .. }),
        "expected AgentEvent::Error, got: {event:?}"
    );
}

#[tokio::test]
async fn watchdog_terminates_agent_exceeding_budget_limit() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_budget_usd = 5.00
"#,
    );

    // Write $6.00 in the current session's log (exceeds limit of $5.00).
    write_fake_budget_session_log(root, "story_b", "coder-1", "sess-budget", 6.00);

    let pool = AgentPool::new_test(3001);
    let tx = pool.inject_test_agent_with_session(
        "story_b",
        "coder-1",
        AgentStatus::Running,
        "sess-budget",
    );
    let mut rx = tx.subscribe();

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(found >= 1, "watchdog should detect the over-budget agent");

    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key("story_b", "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(agent.status, AgentStatus::Failed);
        assert_eq!(
            agent.termination_reason,
            Some(TerminationReason::BudgetLimit),
            "termination reason must be BudgetLimit"
        );
    }

    let event = rx.try_recv().expect("watchdog must emit an Error event");
    assert!(matches!(event, AgentEvent::Error { .. }));
}

#[tokio::test]
async fn watchdog_does_not_terminate_agent_under_limits() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 50
max_budget_usd = 10.00
"#,
    );

    // Agent is under both limits in the current session.
    write_fake_session_log(root, "story_c", "coder-1", "sess-ok", 25);
    write_fake_budget_session_log(root, "story_c", "coder-1", "sess-ok-budget", 3.00);

    let pool = AgentPool::new_test(3001);
    // Use the turns session (the budget session is a separate file;
    // resolve_session_log picks the specified one for turns, and
    // find_latest_log for budget if needed — but here the turns file
    // has 25 turns < 50 so no violation).
    pool.inject_test_agent_with_session("story_c", "coder-1", AgentStatus::Running, "sess-ok");

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert_eq!(found, 0, "agent under limits should not be terminated");

    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key("story_c", "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(
            agent.status,
            AgentStatus::Running,
            "agent under limits should stay Running"
        );
        assert!(agent.termination_reason.is_none());
    }
}

/// Regression test for the original bug 624 incident:
/// coder-1 with max_turns=50, max_budget_usd=5.00 ran 5.6× over the turn
/// limit (280 turns). The watchdog must terminate at the turn limit (turns
/// hit first in the observed trace), with reason TurnLimit.
#[tokio::test]
async fn regression_bug624_coder1_story623_trajectory() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 50
max_budget_usd = 5.00
"#,
    );

    // Simulate the trajectory: 55 turns in the current session (just past
    // the limit) and $2.50 spent (under budget). Turns hit first, so
    // reason should be TurnLimit.
    write_fake_session_log(root, "story_623", "coder-1", "sess-624", 55);

    let pool = AgentPool::new_test(3001);
    let tx = pool.inject_test_agent_with_session(
        "story_623",
        "coder-1",
        AgentStatus::Running,
        "sess-624",
    );
    let mut rx = tx.subscribe();

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(found >= 1, "watchdog must catch the turn-limit violation");

    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key("story_623", "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(agent.status, AgentStatus::Failed);
        assert_eq!(
            agent.termination_reason,
            Some(TerminationReason::TurnLimit),
            "turns hit first in the observed trace, so reason must be TurnLimit"
        );
    }

    // The error event should have been emitted.
    let event = rx.try_recv().expect("watchdog must emit an Error event");
    if let AgentEvent::Error { message, .. } = &event {
        assert!(
            message.contains("turn limit"),
            "error message should mention turn limit, got: {message}"
        );
    } else {
        panic!("expected AgentEvent::Error, got: {event:?}");
    }
}

// ── Kill-respawn loop fix (bug 646), updated for per-session + retry ───

/// When the watchdog terminates an agent for limit-exceeded AND the story
/// has exhausted its retries, it must be marked `blocked: true` in CRDT
/// state so `auto_assign_available_work` won't re-spawn the agent.
///
/// This test seeds a single session that legitimately exceeds the limit
/// and uses `max_retries = 1` so that the first violation blocks.
#[tokio::test]
async fn watchdog_marks_story_blocked_after_limit_termination() {
    crate::db::ensure_content_store();
    crate::crdt_state::init_for_test();

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
max_retries = 1

[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 10
"#,
    );

    // Write story content into the CRDT-backed content store so the
    // watchdog's retry/block path has something to read+update.
    let story_id = "42_story_runaway";
    let initial = "---\nname: Runaway Story\n---\n# Runaway Story\n";
    crate::db::write_content(crate::db::ContentKey::Story(story_id), initial);
    crate::crdt_state::write_item_str(
        story_id,
        "2_current",
        Some("Runaway Story"),
        None,
        None,
        None,
    );

    // 12 turns in a single session exceeds the configured max of 10.
    write_fake_session_log(root, story_id, "coder-1", "sess-runaway", 12);

    let pool = AgentPool::new_test(3001);
    let _tx = pool.inject_test_agent_with_session(
        story_id,
        "coder-1",
        AgentStatus::Running,
        "sess-runaway",
    );

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(found >= 1, "watchdog should detect the over-limit agent");

    // With max_retries=1, the first violation blocks immediately via the state machine.
    let item = crate::crdt_state::read_item(story_id)
        .expect("story must be in CRDT after watchdog termination");
    assert_eq!(
        item.stage().dir_name(),
        "blocked",
        "story stage must be 2_blocked after limit termination with max_retries=1 — got: {}",
        item.stage().dir_name()
    );

    // Sanity: the agent itself is also Failed with the right reason.
    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key(story_id, "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(agent.status, AgentStatus::Failed);
        assert_eq!(
            agent.termination_reason,
            Some(TerminationReason::TurnLimit),
            "termination reason must be TurnLimit"
        );
    }
}

// ── Per-session counting (bug 650) ──────────────────────────────────────

/// Seed multiple prior session log files whose combined assistant-event
/// count exceeds `max_turns`. Then inject a NEW running agent with a
/// fresh session_id whose log has fewer events than `max_turns`.
/// Assert the agent is NOT terminated (per-session count is under the
/// limit) AND the story is NOT marked blocked.
#[tokio::test]
async fn per_session_counting_does_not_terminate_under_limit() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 10
"#,
    );

    // 3 prior sessions with 5 turns each = 15 total (above max_turns=10).
    write_fake_session_log(root, "story_d", "coder-1", "old-sess-1", 5);
    write_fake_session_log(root, "story_d", "coder-1", "old-sess-2", 5);
    write_fake_session_log(root, "story_d", "coder-1", "old-sess-3", 5);

    // New running session has only 3 turns (under limit of 10).
    write_fake_session_log(root, "story_d", "coder-1", "new-sess", 3);

    let pool = AgentPool::new_test(3001);
    pool.inject_test_agent_with_session("story_d", "coder-1", AgentStatus::Running, "new-sess");

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert_eq!(
        found, 0,
        "agent under per-session limit should NOT be terminated"
    );

    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key("story_d", "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(
            agent.status,
            AgentStatus::Running,
            "agent under per-session limit should stay Running"
        );
        assert!(agent.termination_reason.is_none());
    }
}

/// Same setup as per_session_counting_does_not_terminate_under_limit, but
/// the new agent's own session log exceeds `max_turns`. Assert the agent
/// IS terminated AND (with max_retries=1) the story IS marked blocked.
#[tokio::test]
async fn per_session_counting_terminates_over_limit() {
    crate::db::ensure_content_store();
    crate::crdt_state::init_for_test();

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
max_retries = 1

[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 10
"#,
    );

    let story_id = "story_e_per_session";
    crate::db::write_content(
        crate::db::ContentKey::Story(story_id),
        "---\nname: Per-Session Test\n---\n",
    );
    crate::crdt_state::write_item_str(
        story_id,
        "2_current",
        Some("Per-Session Test"),
        None,
        None,
        None,
    );

    // Prior session with 5 turns (under limit alone).
    write_fake_session_log(root, story_id, "coder-1", "old-sess", 5);

    // Current session with 12 turns (over limit).
    write_fake_session_log(root, story_id, "coder-1", "new-sess", 12);

    let pool = AgentPool::new_test(3001);
    let tx =
        pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "new-sess");
    let mut rx = tx.subscribe();

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(
        found >= 1,
        "agent over per-session limit must be terminated"
    );

    {
        let agents = pool.agents.try_lock().unwrap();
        let key = composite_key(story_id, "coder-1");
        let agent = agents.get(&key).unwrap();
        assert_eq!(agent.status, AgentStatus::Failed);
        assert_eq!(agent.termination_reason, Some(TerminationReason::TurnLimit),);
    }

    let event = rx.try_recv().expect("watchdog must emit an Error event");
    assert!(matches!(event, AgentEvent::Error { .. }));

    // With max_retries=1, the story is blocked via the state machine.
    let item = crate::crdt_state::read_item(story_id)
        .expect("story must be in CRDT after per-session overrun");
    assert_eq!(
        item.stage().dir_name(),
        "blocked",
        "story stage must be 2_blocked after per-session overrun with max_retries=1"
    );
}

// ── Retry semantic integration test (bug 650) ───────────────────────────

/// With `max_retries = 3`, simulate separate sessions each exceeding
/// `max_turns`. After session 1: retry_count=1, NOT blocked. After
/// session 2: retry_count=2, NOT blocked. After session 3:
/// retry_count=3 >= max_retries, story IS blocked.
#[tokio::test]
async fn watchdog_retry_semantic_blocks_after_max_retries() {
    crate::db::ensure_content_store();

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
max_retries = 3

[[agent]]
name = "coder-1"
runtime = "claude-code"
max_turns = 10
"#,
    );

    let story_id = "88_story_retry_watchdog";
    let initial = "---\nname: Retry Test\n---\n";
    crate::crdt_state::init_for_test();
    crate::db::write_content(crate::db::ContentKey::Story(story_id), initial);
    crate::crdt_state::write_item_str(story_id, "2_current", Some("Retry Test"), None, None, None);

    // Session 1: exceeds limit → retry_count=1 in CRDT, NOT blocked.
    {
        write_fake_session_log(root, story_id, "coder-1", "session-1", 12);
        let pool = AgentPool::new_test(3001);
        pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "session-1");
        pool.run_watchdog_pass(Some(root)).await;

        let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
        assert_eq!(
            item.retry_count(),
            1,
            "after session 1, retry_count should be 1 in CRDT"
        );
        assert_ne!(
            item.stage().dir_name(),
            "blocked",
            "story should NOT be blocked after session 1"
        );
    }

    // Session 2: exceeds limit → retry_count=2 in CRDT, NOT blocked.
    {
        write_fake_session_log(root, story_id, "coder-1", "session-2", 12);
        let pool = AgentPool::new_test(3001);
        pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "session-2");
        pool.run_watchdog_pass(Some(root)).await;

        let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
        assert_eq!(
            item.retry_count(),
            2,
            "after session 2, retry_count should be 2 in CRDT"
        );
        assert_ne!(
            item.stage().dir_name(),
            "blocked",
            "story should NOT be blocked after session 2"
        );
    }

    // Session 3: exceeds limit → retry_count=3 >= max_retries(3), IS blocked.
    {
        write_fake_session_log(root, story_id, "coder-1", "session-3", 12);
        let pool = AgentPool::new_test(3001);
        pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "session-3");
        pool.run_watchdog_pass(Some(root)).await;

        let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
        assert_eq!(
            item.stage().dir_name(),
            "blocked",
            "story must be blocked after session 3 (retry_count=3 >= max_retries=3) — got: {}",
            item.stage().dir_name()
        );
        // retry_count resets to 0 on stage transition (Bug 780) — the fact
        // that the story reached 2_blocked proves the retry limit was hit.
        assert_eq!(
            item.retry_count(),
            0,
            "retry_count should reset to 0 after stage transition to blocked"
        );
    }
}

// ── Narration filter (story 923) ────────────────────────────────────────

/// Story 923: narration-only assistant turns (text-only, no `tool_use`)
/// must not count against the watchdog's turn budget. A session log with
/// 5 tool turns and 30 narration turns reports turns_used == 5, so an
/// agent with max_tool_turns = 10 stays Running.
#[tokio::test]
async fn watchdog_does_not_count_narration_only_turns() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_tool_turns = 10
max_turns = 200
"#,
    );

    // 5 tool-using turns + 30 narration-only turns.
    // Without the filter, total turns = 35 > max_tool_turns = 10 → kill.
    // With the filter, tool turns = 5 ≤ 10 → stay Running.
    write_fake_mixed_session_log(root, "story_923", "coder-1", "sess-narr", 5, 30);

    let pool = AgentPool::new_test(3001);
    pool.inject_test_agent_with_session("story_923", "coder-1", AgentStatus::Running, "sess-narr");

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert_eq!(
        found, 0,
        "agent must not be terminated: only 5 tool turns of a 10-turn budget"
    );

    let agents = pool.agents.try_lock().unwrap();
    let key = composite_key("story_923", "coder-1");
    let agent = agents.get(&key).unwrap();
    assert_eq!(agent.status, AgentStatus::Running);
    assert!(agent.termination_reason.is_none());
}

/// Story 923: max_tool_turns takes precedence over max_turns when both are
/// set. With max_tool_turns = 3 and max_turns = 200, an agent that has 4
/// tool turns is killed even though total turns (4) is far below max_turns.
#[tokio::test]
async fn watchdog_max_tool_turns_overrides_max_turns() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_project_config(
        root,
        r#"
[[agent]]
name = "coder-1"
runtime = "claude-code"
max_tool_turns = 3
max_turns = 200
"#,
    );

    write_fake_mixed_session_log(root, "story_923b", "coder-1", "sess-over", 4, 0);

    let pool = AgentPool::new_test(3001);
    let tx = pool.inject_test_agent_with_session(
        "story_923b",
        "coder-1",
        AgentStatus::Running,
        "sess-over",
    );
    let mut rx = tx.subscribe();

    let found = pool.run_watchdog_pass(Some(root)).await;
    assert!(
        found >= 1,
        "watchdog must terminate when tool turns exceed max_tool_turns"
    );

    let agents = pool.agents.try_lock().unwrap();
    let key = composite_key("story_923b", "coder-1");
    let agent = agents.get(&key).unwrap();
    assert_eq!(agent.status, AgentStatus::Failed);
    assert_eq!(agent.termination_reason, Some(TerminationReason::TurnLimit));

    let event = rx.try_recv().expect("watchdog must emit an Error event");
    assert!(matches!(event, AgentEvent::Error { .. }));
}
