//! PTY runner — spawns agent processes in pseudo-terminals and streams their output.

mod events;
mod runner;
mod types;

pub(in crate::agents) use events::emit_event;
pub(in crate::agents) use runner::run_agent_pty_streaming;

#[cfg(test)]
mod tests {
    use super::events::handle_agent_stream_event;
    use super::*;
    use crate::agents::AgentEvent;
    use crate::io::watcher::WatcherEvent;
    use std::sync::{Arc, Mutex};
    use tokio::sync::broadcast;

    // ── AC1: pty detects rate_limit_event and emits RateLimitWarning ─────────

    /// Verify that when a `rate_limit_event` JSON line appears in PTY output,
    /// `run_agent_pty_streaming` sends a `WatcherEvent::RateLimitWarning` with
    /// the correct story_id and agent_name.
    ///
    /// The command invoked is: `sh -p -- <script>` where `--` terminates
    /// option parsing so the script path is treated as the operand.
    #[tokio::test]
    async fn rate_limit_event_json_sends_watcher_warning() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("emit_rate_limit.sh");
        std::fs::write(
            &script,
            "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed_warning\"}}'\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        // sh -p "--" <script>: -p = privileged mode, "--" = end options,
        // then the script path is the file operand.
        let result = run_agent_pty_streaming(
            "365_story_test",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            0,
            watcher_tx,
            None,
            None,
        )
        .await;

        assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err());

        let evt = watcher_rx
            .try_recv()
            .expect("Expected a RateLimitWarning to be sent on watcher_tx");
        match evt {
            WatcherEvent::RateLimitWarning {
                story_id,
                agent_name,
            } => {
                assert_eq!(story_id, "365_story_test");
                assert_eq!(agent_name, "coder-1");
            }
            other => panic!("Expected RateLimitWarning, got: {other:?}"),
        }
    }

    /// AC1: hard block with `reset_at` emits `RateLimitHardBlock` with the
    /// correct story_id, agent_name, and parsed reset_at timestamp.
    #[tokio::test]
    async fn rate_limit_hard_block_sends_watcher_hard_block_event() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("emit_hard_block.sh");
        std::fs::write(
            &script,
            "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"hard_block\",\"reset_at\":\"2099-01-01T12:00:00Z\"}}'\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let result = run_agent_pty_streaming(
            "423_story_rate_limit",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            0,
            watcher_tx,
            None,
            None,
        )
        .await;

        assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err());

        let evt = watcher_rx
            .try_recv()
            .expect("Expected a RateLimitHardBlock to be sent on watcher_tx");
        match evt {
            WatcherEvent::RateLimitHardBlock {
                story_id,
                agent_name,
                reset_at,
            } => {
                assert_eq!(story_id, "423_story_rate_limit");
                assert_eq!(agent_name, "coder-1");
                assert_eq!(
                    reset_at.to_rfc3339(),
                    "2099-01-01T12:00:00+00:00",
                    "reset_at should match the parsed timestamp"
                );
            }
            other => panic!("Expected RateLimitHardBlock, got: {other:?}"),
        }
    }

    /// Bug 496: hard block WITHOUT `reset_at` must still emit `RateLimitHardBlock`
    /// (not `RateLimitWarning`), using a default 5-minute backoff so the
    /// auto-scheduler can set a retry timer.
    #[tokio::test]
    async fn rate_limit_hard_block_without_reset_at_sends_hard_block_event() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("emit_hard_block_no_reset.sh");
        std::fs::write(
            &script,
            "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"rejected\"}}'\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let before = chrono::Utc::now();
        let result = run_agent_pty_streaming(
            "496_bug_hard_rate_limit",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            0,
            watcher_tx,
            None,
            None,
        )
        .await;
        let after = chrono::Utc::now();

        assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err());

        let evt = watcher_rx
            .try_recv()
            .expect("Expected a RateLimitHardBlock to be sent on watcher_tx");
        match evt {
            WatcherEvent::RateLimitHardBlock {
                story_id,
                agent_name,
                reset_at,
            } => {
                assert_eq!(story_id, "496_bug_hard_rate_limit");
                assert_eq!(agent_name, "coder-1");
                // reset_at should be ~5 minutes from when the event fired
                let min_expected = before + chrono::Duration::minutes(4);
                let max_expected = after + chrono::Duration::minutes(6);
                assert!(
                    reset_at >= min_expected && reset_at <= max_expected,
                    "reset_at {reset_at} should be ~5 minutes from now"
                );
            }
            other => panic!("Expected RateLimitHardBlock (with default backoff), got: {other:?}"),
        }
    }

    /// Story 916: a rate-limit hard block must extend the inactivity deadline
    /// by the backoff duration so the watchdog doesn't kill the agent while
    /// it's legitimately waiting for the limit to clear.
    ///
    /// Script emits a hard-block event with reset_at in the far future, then
    /// sleeps 3s, then exits.  With inactivity_timeout_secs = 1, the run
    /// would normally fail at the 1s mark; with the extension the deadline
    /// is bumped past the sleep and the script completes cleanly.  The
    /// far-future reset_at avoids wall-clock races under cargo-test load.
    #[tokio::test]
    async fn rate_limit_hard_block_extends_inactivity_deadline() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("emit_then_wait.sh");
        let body = "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"hard_block\",\"reset_at\":\"2099-01-01T12:00:00Z\"}}'\nsleep 3\n";
        std::fs::write(&script, body).unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, _watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let result = run_agent_pty_streaming(
            "916_story_rate_limit_extension",
            "mergemaster",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            1, // inactivity_timeout_secs = 1s; would expire before the 3s sleep without the extension
            watcher_tx,
            None,
            None,
        )
        .await;

        assert!(
            result.is_ok(),
            "PTY run should not be killed by inactivity timeout during rate-limit block: {:?}",
            result.err()
        );
    }

    // ── story 1196: in-flight MCP tool calls suspend the inactivity deadline ──

    /// A `tool_use` block in an `assistant` message marks a call as in
    /// flight; the inactivity deadline must be suspended while it is
    /// outstanding (so a slow MCP tool like `run_tests` doesn't get the
    /// agent killed), and must resume as soon as the matching `tool_result`
    /// arrives in a `user` message.
    ///
    /// Script: emits a tool_use, sleeps 2s (would fail a 1s timeout without
    /// suspension), emits the matching tool_result, then sleeps 2s again
    /// (now with no call in flight, the 1s timeout must fire).
    #[tokio::test]
    async fn tool_call_in_flight_suspends_then_resumes_inactivity_deadline() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("tool_call_then_silence.sh");
        let body = "#!/bin/sh\n\
             printf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"tool1\",\"name\":\"run_tests\",\"input\":{}}]}}'\n\
             sleep 2\n\
             printf '%s\\n' '{\"type\":\"user\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"tool1\",\"content\":\"ok\"}]}}'\n\
             sleep 2\n";
        std::fs::write(&script, body).unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, _watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let result = run_agent_pty_streaming(
            "1196_story_tool_call_in_flight",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            1, // inactivity_timeout_secs = 1s
            watcher_tx,
            None,
            None,
        )
        .await;

        match result {
            Err(err) => assert!(
                err.contains("inactivity timeout"),
                "expected an inactivity timeout error after the tool call resolved, got: {err}"
            ),
            Ok(_) => panic!(
                "agent must still be killed once the tool call resolves and \
                 the process falls genuinely silent again"
            ),
        }
    }

    /// A genuinely hung agent (no output at all, no tool call in flight)
    /// must still be killed by the inactivity watchdog after the configured
    /// timeout — the suspension added for in-flight tool calls must not
    /// mask a real hang.
    #[tokio::test]
    async fn genuinely_silent_agent_with_no_in_flight_call_is_killed() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("silent.sh");
        std::fs::write(&script, "#!/bin/sh\nsleep 2\n").unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, _watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let result = run_agent_pty_streaming(
            "1196_story_genuine_hang",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            1, // inactivity_timeout_secs = 1s
            watcher_tx,
            None,
            None,
        )
        .await;

        match result {
            Err(err) => assert!(err.contains("inactivity timeout")),
            Ok(_) => panic!("a genuinely silent agent with no in-flight tool call must be killed"),
        }
    }

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

        let log_writer =
            crate::agent_log::AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit")
                .unwrap();
        let log_mutex = Mutex::new(log_writer);

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());

        let event = AgentEvent::Status {
            story_id: "42_story_foo".to_string(),
            agent_name: "coder-1".to_string(),
            status: "running".to_string(),
        };

        emit_event(event, &tx, &event_log, Some(&log_mutex));

        // Verify event was added to in-memory log
        let mem_events = event_log.lock().unwrap();
        assert_eq!(mem_events.len(), 1);
        drop(mem_events);

        // Verify event was written to the log file
        let log_path =
            crate::agent_log::log_file_path(root, "42_story_foo", "coder-1", "sess-emit");
        let entries = crate::agent_log::read_log(&log_path).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].event["type"], "status");
        assert_eq!(entries[0].event["status"], "running");
    }

    // ── bug 167: handle_agent_stream_event routes thinking/text correctly ───

    #[test]
    fn stream_event_thinking_delta_emits_thinking_event() {
        let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
        let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());

        let event = serde_json::json!({
            "type": "content_block_delta",
            "delta": {"type": "thinking_delta", "thinking": "Let me analyze this..."}
        });

        handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);

        let received = rx.try_recv().unwrap();
        match received {
            AgentEvent::Thinking {
                story_id,
                agent_name,
                text,
            } => {
                assert_eq!(story_id, "s1");
                assert_eq!(agent_name, "coder-1");
                assert_eq!(text, "Let me analyze this...");
            }
            other => panic!("Expected Thinking event, got: {other:?}"),
        }
    }

    #[test]
    fn stream_event_text_delta_emits_output_event() {
        let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
        let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());

        let event = serde_json::json!({
            "type": "content_block_delta",
            "delta": {"type": "text_delta", "text": "Here is the result."}
        });

        handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);

        let received = rx.try_recv().unwrap();
        match received {
            AgentEvent::Output {
                story_id,
                agent_name,
                text,
            } => {
                assert_eq!(story_id, "s1");
                assert_eq!(agent_name, "coder-1");
                assert_eq!(text, "Here is the result.");
            }
            other => panic!("Expected Output event, got: {other:?}"),
        }
    }

    #[test]
    fn stream_event_input_json_delta_ignored() {
        let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
        let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());

        let event = serde_json::json!({
            "type": "content_block_delta",
            "delta": {"type": "input_json_delta", "partial_json": "{\"file\":"}
        });

        handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);

        // No event should be emitted for tool argument deltas
        assert!(rx.try_recv().is_err());
    }

    #[test]
    fn stream_event_non_delta_type_ignored() {
        let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
        let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());

        let event = serde_json::json!({
            "type": "message_start",
            "message": {"role": "assistant"}
        });

        handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);

        assert!(rx.try_recv().is_err());
    }

    // ── bug 967: eager session recording survives watchdog kill + task abort ──

    /// AC2 regression: simulates a watchdog kill of an agent that emitted a
    /// session_id mid-run.  The script emits a `"system"` JSON event and then
    /// sleeps; a concurrent task kills the child after 500 ms (simulating the
    /// watchdog).  The eager-recording path in `run_agent_pty_blocking` must
    /// have already persisted the session_id before the kill, so
    /// `lookup_session` returns it (warm) rather than `None` (cold).
    #[tokio::test]
    async fn watchdog_kill_session_id_survives_abort() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let project_root = tmp.path().to_path_buf();
        std::fs::create_dir_all(project_root.join(".huskies")).unwrap();

        // Script emits a system event immediately, then sleeps so the process
        // stays alive long enough for us to kill it (simulating the watchdog).
        let script = tmp.path().join("emit_then_sleep.sh");
        std::fs::write(
            &script,
            "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"session_id\":\"sess-967-watchdog\"}'\nsleep 60\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, _watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        // Spawn a task to kill the child after a short delay (simulating watchdog).
        // Uses pids_matching on the script path — same mechanism as the production
        // watchdog after the process_kill migration (story 1090).
        let script_path_for_kill = script.to_string_lossy().to_string();
        tokio::spawn(async move {
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            let pids = crate::process_kill::pids_matching(&script_path_for_kill);
            if !pids.is_empty() {
                let _ = crate::process_kill::sigkill_pids_and_verify(&pids);
            }
        });

        // Run the PTY directly — it returns once the child is killed.
        let script_arg = script.to_string_lossy().to_string();
        let _ = run_agent_pty_streaming(
            "967_story_watchdog",
            "coder-1",
            "sh",
            &[script_arg],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            0, // no inactivity timeout
            watcher_tx,
            None, // no session to resume
            Some((project_root.clone(), "sonnet".to_string())),
        )
        .await;

        // The session_id must be in the store (eagerly recorded when the
        // "system" event was seen, before the kill).
        let recorded = crate::agents::session_store::lookup_session(
            &project_root,
            "967_story_watchdog",
            "coder-1",
            "sonnet",
        );
        assert_eq!(
            recorded,
            Some("sess-967-watchdog".to_string()),
            "session_id must be recorded eagerly before the watchdog kill so \
             the respawn's lookup_session returns it (warm), not None (cold)"
        );
    }

    // ── bug 1103: soft rate-limit warning (status=allowed) must NOT set rate_limit_exit ──

    /// Regression: a `rate_limit_event` with `status="allowed"` is a soft
    /// warning — the request was permitted.  The session that follows should
    /// complete normally and report `rate_limit_exit == false`, not trigger the
    /// rate-limit respawn path in the spawn handler.
    #[tokio::test]
    async fn rate_limit_allowed_status_does_not_set_rate_limit_exit() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("emit_allowed_then_exit.sh");
        // Emit status="allowed" (soft warning), then exit cleanly.
        std::fs::write(
            &script,
            "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\",\"reset_at\":\"2099-01-01T12:00:00Z\"}}'\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
        let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
        let event_log = Arc::new(Mutex::new(Vec::new()));

        let result = run_agent_pty_streaming(
            "1103_soft_warning_no_exit_flag",
            "coder-1",
            "sh",
            &[script.to_string_lossy().to_string()],
            "--",
            "/tmp",
            &tx,
            &event_log,
            None,
            0,
            watcher_tx,
            None,
            None,
        )
        .await;

        let pty = result.expect("PTY run should succeed");
        assert!(
            !pty.rate_limit_exit,
            "rate_limit_exit must be false for a soft 'allowed' warning; \
             only genuine hard blocks (rejected) should set it"
        );

        // Watcher must have received RateLimitWarning, not RateLimitHardBlock.
        let evt = watcher_rx
            .try_recv()
            .expect("Expected a RateLimitWarning watcher event");
        assert!(
            matches!(evt, WatcherEvent::RateLimitWarning { .. }),
            "Expected RateLimitWarning for status=allowed, got: {evt:?}"
        );
    }
}
