//! Unit tests for the turn-finalize rails in [`super`] (`turn_completion`),
//! split out via `#[path]` to keep the module itself small.

use super::*;
use crate::app::agent::AgentState;
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEventBlock;
use crate::scrollback::state::ScrollbackState;
use std::path::PathBuf;
use std::time::Instant;

fn last_session_event(sb: &ScrollbackState) -> Option<SessionEvent> {
    (0..sb.len())
        .rev()
        .find_map(|i| match sb.get(i).map(|e| &e.block) {
            Some(RenderBlock::SessionEvent(b)) => Some(b.event.clone()),
            _ => None,
        })
}

/// A viewer in TurnRunning with an adopted prompt id, ready to be finalized.
fn running_viewer(prompt_id: &str) -> AgentView {
    let mut agent = super::super::agent_view::test_agent_view(Some("s1"), PathBuf::from("/tmp"));
    agent.attached_as_viewer = true;
    agent.session.start_turn(&mut agent.scrollback);
    agent.session.current_prompt_id = Some(prompt_id.into());
    agent.turn_started_at = Some(Instant::now());
    agent
}

/// A driver in TurnRunning with a local prompt id (default
/// `attached_as_viewer == false`).
fn running_driver(prompt_id: &str) -> AgentView {
    let mut agent = super::super::agent_view::test_agent_view(Some("s1"), PathBuf::from("/tmp"));
    agent.session.start_turn(&mut agent.scrollback);
    agent.session.current_prompt_id = Some(prompt_id.into());
    agent.turn_started_at = Some(Instant::now());
    agent
}

#[test]
fn viewer_finalize_idles_and_pushes_completed_marker() {
    let mut agent = running_viewer("p1");
    let outcome =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    assert!(matches!(outcome, TerminalApply::ViewerFinalized));
    assert!(agent.session.state.is_idle());
    assert!(agent.session.current_prompt_id.is_none());
    assert!(agent.turn_started_at.is_none());
    assert!(matches!(
        last_session_event(&agent.scrollback),
        Some(SessionEvent::TurnCompleted { .. })
    ));
}

fn one_stop_group() -> Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)> {
    use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
    vec![(
        "stop".to_string(),
        vec![HookRunEntry {
            name: "global/notify".into(),
            status: HookRunStatus::Success {
                elapsed: std::time::Duration::from_millis(12),
            },
            output: None,
        }],
    )]
}

/// Stop-hook groups attached to the last session-event marker.
fn last_marker_groups(sb: &ScrollbackState) -> Option<usize> {
    (0..sb.len())
        .rev()
        .find_map(|i| match sb.get(i).map(|e| &e.block) {
            Some(RenderBlock::SessionEvent(b)) => Some(b.stop_hooks.len()),
            _ => None,
        })
}

fn count_lifecycle_blocks(sb: &ScrollbackState) -> usize {
    use crate::scrollback::blocks::tool::ToolCallBlock;
    (0..sb.len())
        .filter(|i| {
            matches!(
                sb.get(*i).map(|e| &e.block),
                Some(RenderBlock::ToolCall(ToolCallBlock::Lifecycle(_)))
            )
        })
        .count()
}

#[test]
fn marker_push_consumes_matching_stop_hook_stash() {
    let mut agent = running_driver("p1");
    agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
        prompt_id: Some("p1".into()),
        groups: one_stop_group(),
    });

    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        Some("p1"),
        false,
    );

    assert_eq!(
        last_marker_groups(&agent.scrollback),
        Some(1),
        "the stash must fold into the marker"
    );
    assert!(agent.pending_stop_hooks.is_none());
    assert_eq!(count_lifecycle_blocks(&agent.scrollback), 0);
}

#[test]
fn marker_push_flushes_stale_stash_standalone() {
    // A stash stamped with another turn's prompt id must not attach to
    // this marker — it flushes as the legacy standalone block.
    let mut agent = running_driver("p2");
    agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
        prompt_id: Some("p1".into()),
        groups: one_stop_group(),
    });

    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        Some("p2"),
        false,
    );

    assert_eq!(
        last_marker_groups(&agent.scrollback),
        Some(0),
        "a stale stash must not attach to the new marker"
    );
    assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
    assert!(agent.pending_stop_hooks.is_none());
}

#[test]
fn marker_without_ending_pid_flushes_stamped_stash_standalone() {
    // A stamped stash can't be confirmed against a marker whose ending
    // turn id is missing — it flushes standalone instead of folding into
    // a marker it may not belong to.
    let mut agent = running_driver("p1");
    agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
        prompt_id: Some("p1".into()),
        groups: one_stop_group(),
    });

    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        None,
        false,
    );

    assert_eq!(
        last_marker_groups(&agent.scrollback),
        Some(0),
        "an unconfirmable stamped stash must not attach to the marker"
    );
    assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
    assert!(agent.pending_stop_hooks.is_none());
}

#[test]
fn no_marker_flushes_stash_as_standalone_block() {
    // Turn ends without a marker (bash turn / rate-limit UX): the held
    // hooks still surface, in the legacy standalone form.
    let mut agent = running_driver("p1");
    agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
        prompt_id: Some("p1".into()),
        groups: one_stop_group(),
    });

    push_turn_terminal_marker(&mut agent, None, Some("p1"), false);

    assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
    assert!(agent.pending_stop_hooks.is_none());
}

#[test]
fn viewer_finalize_consumes_stop_hook_stash() {
    // A viewer that stashed hooks mid-turn folds them into the marker the
    // finalize pushes.
    let mut agent = running_viewer("p1");
    agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
        prompt_id: Some("p1".into()),
        groups: one_stop_group(),
    });

    let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);

    assert_eq!(last_marker_groups(&agent.scrollback), Some(1));
    assert!(agent.pending_stop_hooks.is_none());
}

#[test]
fn viewer_finalize_duplicate_terminal_is_noop() {
    let mut agent = running_viewer("p1");
    let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    let len_after_first = agent.scrollback.len();

    // A duplicate/stale terminal for the now-finished turn does nothing.
    let outcome =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    assert!(matches!(outcome, TerminalApply::Ignored));
    assert!(agent.session.state.is_idle());
    assert_eq!(
        agent.scrollback.len(),
        len_after_first,
        "a duplicate terminal must not push a second marker"
    );
}

#[test]
fn viewer_finalize_stop_reason_to_marker_mapping() {
    // cancelled → Turn cancelled.
    let mut agent = running_viewer("p1");
    let _ =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
    assert!(matches!(
        last_session_event(&agent.scrollback),
        Some(SessionEvent::TurnCancelled { .. })
    ));

    // error (+agentResult) → Turn failed carrying the error text.
    let mut agent = running_viewer("p1");
    let _ = finalize_turn_from_terminal(
        &mut agent,
        "s1",
        Some("p1"),
        Some("error"),
        Some("boom"),
        None,
    );
    match last_session_event(&agent.scrollback) {
        Some(SessionEvent::TurnFailed { error, .. }) => assert_eq!(error, "boom"),
        other => panic!("expected TurnFailed, got {other:?}"),
    }

    // rate_limit → finished, but no marker (not actionable from a viewer).
    let mut agent = running_viewer("p1");
    let _ =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("rate_limit"), None, None);
    assert!(agent.session.state.is_idle());
    assert!(
        last_session_event(&agent.scrollback).is_none(),
        "rate_limit must not push a marker on a viewer"
    );

    // unknown/other reason → Turn completed (the catch-all).
    let mut agent = running_viewer("p1");
    let _ =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("max_tokens"), None, None);
    assert!(matches!(
        last_session_event(&agent.scrollback),
        Some(SessionEvent::TurnCompleted { .. })
    ));
}

#[test]
fn driver_arms_reconcile_and_does_not_finish() {
    let mut agent = running_driver("p1");
    let outcome =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
    assert!(matches!(outcome, TerminalApply::ReconcileArmed));
    assert!(
        matches!(agent.session.state, AgentState::TurnRunning),
        "the driver's turn must NOT be finished — the PromptResponse RPC owns it"
    );
    let pending = agent
        .pending_turn_end_reconcile
        .as_ref()
        .expect("the driver's awaited turn must arm a reconcile");
    assert_eq!(pending.prompt_id, "p1");
    assert_eq!(pending.stop_reason.as_deref(), Some("cancelled"));
}

#[test]
fn driver_mismatched_prompt_id_does_not_arm() {
    // Stale/peer terminal must not arm reconcile on a different live turn.
    let mut agent = running_driver("p1");
    let outcome = finalize_turn_from_terminal(
        &mut agent,
        "s1",
        Some("p-other"),
        Some("end_turn"),
        None,
        None,
    );
    assert!(matches!(outcome, TerminalApply::Ignored));
    assert!(agent.pending_turn_end_reconcile.is_none());
    assert!(matches!(agent.session.state, AgentState::TurnRunning));
}

#[test]
fn driver_missing_prompt_id_arms_against_current_when_idle_in_turn() {
    let mut agent = running_driver("p1");
    let outcome = finalize_turn_from_terminal(&mut agent, "s1", None, Some("end_turn"), None, None);
    assert!(matches!(outcome, TerminalApply::ReconcileArmed));
    assert_eq!(
        agent.pending_turn_end_reconcile.as_ref().unwrap().prompt_id,
        "p1"
    );
}

fn stream_agent_text(agent: &mut AgentView, text: &str) {
    use crate::acp::meta::NotificationMeta;
    use agent_client_protocol as acp;
    let meta = NotificationMeta::default();
    let _ = agent.session.tracker.handle_update(
        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
            acp::TextContent::new(text),
        ))),
        &meta,
        &mut agent.scrollback,
    );
}

/// Missing wire pid still arms lost-PR reconcile (full teardown
/// lives in `reconcile_overdue_turn_ends` / PromptResponse).
#[test]
fn repro_terminal_without_prompt_id_arms_reconcile_for_lost_pr() {
    let mut agent = running_driver("p1");
    stream_agent_text(&mut agent, "done");
    let outcome = finalize_turn_from_terminal(&mut agent, "s1", None, Some("end_turn"), None, None);
    assert!(matches!(outcome, TerminalApply::ReconcileArmed));
    assert!(agent.pending_turn_end_reconcile.is_some());
    assert!(
        matches!(agent.session.state, AgentState::TurnRunning),
        "driver stays TurnRunning until PR or overdue reconcile"
    );
    assert_eq!(
        agent.session.tracker.activity(),
        Some(crate::acp::tracker::TurnActivity::Responding)
    );
}

/// Exact pid still arms (control).
#[test]
fn recovery_mode_matching_turn_completed_arms_reconcile_for_lost_pr() {
    let mut agent = running_driver("p1");
    stream_agent_text(&mut agent, "done");

    let outcome =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    assert!(matches!(outcome, TerminalApply::ReconcileArmed));
    assert!(matches!(agent.session.state, AgentState::TurnRunning));
    assert!(agent.pending_turn_end_reconcile.is_some());
}

/// Re-arm same pid keeps earliest received_at (does not extend grace forever).
#[test]
fn driver_rearm_same_pid_preserves_received_at() {
    let mut agent = running_driver("p1");
    let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    let first = agent
        .pending_turn_end_reconcile
        .as_ref()
        .unwrap()
        .received_at;
    std::thread::sleep(std::time::Duration::from_millis(5));
    let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
    let second = agent
        .pending_turn_end_reconcile
        .as_ref()
        .unwrap()
        .received_at;
    assert_eq!(first, second);
}

// ── EndLine snapshots: work suffix + between-turns status window ──

use crate::scrollback::blocks::EndWork;

fn insert_bg_task(agent: &mut AgentView, task_id: &str, is_monitor: bool) {
    agent.session.bg_tasks.insert(
        task_id.into(),
        crate::app::agent::BgTaskState {
            task_id: task_id.into(),
            tool_call_id: format!("call-{task_id}"),
            command: "sleep 5".into(),
            description: None,
            cwd: "/tmp".into(),
            output_file: "/tmp/out".into(),
            status: crate::app::agent::BgTaskStatus::Running,
            start_time: std::time::SystemTime::now(),
            end_time: None,
            exit_code: None,
            signal: None,
            stdout: String::new(),
            stdout_line_count: 0,
            truncated: false,
            pending_kill: false,
            kill_requested_at: None,
            scrollback_entry_id: None,
            is_monitor,
            restored_from_replay: false,
        },
    );
}

/// The newest session-event marker block.
fn last_marker_block(agent: &AgentView) -> &SessionEventBlock {
    (0..agent.scrollback.len())
        .rev()
        .find_map(|i| match agent.scrollback.get(i).map(|e| &e.block) {
            Some(RenderBlock::SessionEvent(b)) => Some(b),
            _ => None,
        })
        .expect("a session-event marker must exist")
}

#[test]
fn real_end_marker_snapshots_running_work() {
    let mut agent = running_driver("p1");
    insert_bg_task(&mut agent, "bg-1", false);

    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        Some("p1"),
        false,
    );

    let block = last_marker_block(&agent);
    assert!(!block.parked);
    assert_eq!(block.prompt_id.as_deref(), Some("p1"));
    assert_eq!(
        block.end_work,
        Some(EndWork {
            running_commands: 1,
            ..EndWork::default()
        })
    );
    assert_eq!(
        block.marker_text(),
        "Worked for 2.0s. 1 command still running."
    );
    assert!(
        agent.end_work_announced,
        "announcing work opens the between-turns status window"
    );
}

#[test]
fn workless_marker_stays_legacy_and_closes_window() {
    let mut agent = running_driver("p1");
    agent.end_work_announced = true;

    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        Some("p1"),
        false,
    );

    let block = last_marker_block(&agent);
    assert!(block.end_work.is_none(), "legacy marker: no work suffix");
    assert_eq!(block.marker_text(), "Worked for 2.0s.");
    assert!(
        !agent.end_work_announced,
        "a workless marker proves nothing is running — window closed"
    );
}

#[test]
fn marker_snapshot_never_mutates() {
    // The suffix is a push-time snapshot: later completions re-emit a
    // fresh status line instead of editing the marker.
    let mut agent = running_driver("p1");
    insert_bg_task(&mut agent, "bg-1", false);
    push_turn_terminal_marker(
        &mut agent,
        Some(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }),
        Some("p1"),
        false,
    );

    agent.session.bg_tasks.get_mut("bg-1").unwrap().status = crate::app::agent::BgTaskStatus::Done;

    assert_eq!(
        last_marker_block(&agent).marker_text(),
        "Worked for 2.0s. 1 command still running.",
        "the marker keeps its push-time counts"
    );
}

// ── Send-now cancel marker suppression (viewer finalize rail) ────────

/// A viewer finalizing a `send_now`-stamped `cancelled` terminal pushes no marker.
#[test]
fn viewer_finalize_suppresses_send_now_cancel_marker() {
    let mut agent = running_viewer("p1");
    let outcome = finalize_turn_from_terminal(
        &mut agent,
        "s1",
        Some("p1"),
        Some("cancelled"),
        None,
        Some("send_now"),
    );
    assert!(matches!(outcome, TerminalApply::ViewerFinalized));
    assert!(agent.session.state.is_idle(), "the turn still finishes");
    assert!(
        last_session_event(&agent.scrollback).is_none(),
        "a send-now cancel pushes no marker on a viewer"
    );

    // A non-send-now trigger keeps the marker even with a local expectation armed (wire is authoritative).
    let mut agent = running_viewer("p1");
    agent.expect_send_now_cancel = Some("p-mine".into());
    let _ = finalize_turn_from_terminal(
        &mut agent,
        "s1",
        Some("p1"),
        Some("cancelled"),
        None,
        Some("ctrl_c"),
    );
    assert!(matches!(
        last_session_event(&agent.scrollback),
        Some(SessionEvent::TurnCancelled { .. })
    ));

    // Older shell (no meta): the armed expectation is the fallback.
    let mut agent = running_viewer("p1");
    agent.expect_send_now_cancel = Some("p-mine".into());
    let _ =
        finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
    assert!(
        last_session_event(&agent.scrollback).is_none(),
        "the armed expectation suppresses the marker without wire meta"
    );
    assert!(
        agent.expect_send_now_cancel.is_none(),
        "the expectation is consumed on the viewer finalize"
    );
}

/// The driver arm records the broadcast's `cancelTrigger` for a lost-RPC reconcile.
#[test]
fn driver_arm_records_cancel_trigger_for_reconcile() {
    let mut agent = running_driver("p1");
    let outcome = finalize_turn_from_terminal(
        &mut agent,
        "s1",
        Some("p1"),
        Some("cancelled"),
        None,
        Some("send_now"),
    );
    assert!(matches!(outcome, TerminalApply::ReconcileArmed));
    assert_eq!(
        agent
            .pending_turn_end_reconcile
            .as_ref()
            .and_then(|p| p.cancel_trigger.as_deref()),
        Some("send_now")
    );
}
