//! Integration test for the **session-limit** wiring in the composition root
//! (ARCHITECTURE §21, LS7).
//!
//! Proves that [`AppState`] actually *constructs and wires* the
//! `SessionLimitService` (the gap LS7 closes: the LS1–LS4 domain/application
//! machinery existed but was never instantiated at runtime). The detect→plan→
//! resume→cancel logic itself is covered by the application/infrastructure unit
//! tests (LS3/LS4); here we assert the **composition** the commands rely on:
//! the service is present, `on_rate_limited` arms a cancellable resume that emits
//! the domain events on the real bus, and `cancel_resume` is a clean no-op for an
//! unknown agent (never a panic).

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use app_tauri_lib::state::AppState;
use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ids::{NodeId, ProjectId, SessionId};
use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream};
use domain::AgentId;
use infrastructure::UuidGenerator;

fn temp_path(tag: &str) -> PathBuf {
    let ids = UuidGenerator::new();
    std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid()))
}

fn agent(n: u128) -> AgentId {
    AgentId::from_uuid(uuid::Uuid::from_u128(n))
}

fn node(n: u128) -> NodeId {
    NodeId::from_uuid(uuid::Uuid::from_u128(n))
}

fn project_id(n: u128) -> ProjectId {
    ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}

fn session(n: u128) -> SessionId {
    SessionId::from_uuid(uuid::Uuid::from_u128(n))
}

struct LiveStructuredSession {
    id: SessionId,
    conversation_id: Option<String>,
}

#[async_trait]
impl AgentSession for LiveStructuredSession {
    fn id(&self) -> SessionId {
        self.id
    }

    fn conversation_id(&self) -> Option<String> {
        self.conversation_id.clone()
    }

    async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
        Ok(Box::new(std::iter::empty()))
    }

    async fn shutdown(&self) -> Result<(), AgentSessionError> {
        Ok(())
    }
}

/// `cancel_resume` on an agent with no armed resume returns `false` (and never
/// panics): the service is wired and behaves as a clean no-op.
#[tokio::test]
async fn cancel_resume_is_a_clean_noop_for_unknown_agent() {
    let state = AppState::build(temp_path("appdata"));
    assert!(
        !state.session_limit_service.cancel_resume(agent(1)),
        "no armed resume ⇒ cancel returns false"
    );
}

/// A structured rate-limit signal with a **future** reset arms a cancellable
/// resume: the wired service publishes `AgentRateLimited` then
/// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then
/// publishes `AgentResumeCancelled` and returns `true`.
#[tokio::test]
async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
    let state = AppState::build(temp_path("appdata"));
    let mut rx = state.event_bus.raw_receiver();

    // Reset far in the future so the scheduler does NOT fire before we cancel.
    let resets_at_ms = i64::MAX;
    state.session_limit_service.on_rate_limited(
        project_id(1),
        agent(7),
        node(70),
        None,
        Some(resets_at_ms),
    );

    // The two arming events, in order, on the bus.
    let mut saw_rate_limited = false;
    let mut saw_scheduled = false;
    for _ in 0..32 {
        match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
            Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
                saw_rate_limited = true;
            }
            Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
                saw_scheduled = true;
                break;
            }
            Ok(Ok(_)) => continue,
            _ => break,
        }
    }
    assert!(saw_rate_limited, "AgentRateLimited relayed on the bus");
    assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus");

    // The window is cancellable: cancel succeeds and emits AgentResumeCancelled.
    assert!(
        state.session_limit_service.cancel_resume(agent(7)),
        "an armed resume is cancellable"
    );
    let mut saw_cancelled = false;
    for _ in 0..32 {
        match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
            Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => {
                saw_cancelled = true;
                break;
            }
            Ok(Ok(_)) => continue,
            _ => break,
        }
    }
    assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
}

/// B1/B2 direct : le tap structuré doit résoudre `agent_id` + `node_id` +
/// `conversation_id` depuis le registre vivant avant d'appeler `on_rate_limited`.
/// Avec un reset connu et une conversation moteur connue, le service publie
/// `AgentRateLimited` puis `AgentResumeScheduled`.
#[tokio::test]
async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() {
    let state = AppState::build(temp_path("appdata"));
    let mut rx = state.event_bus.raw_receiver();
    let sid = session(707);
    state.structured_sessions.insert(
        Arc::new(LiveStructuredSession {
            id: sid,
            conversation_id: Some("engine-main".to_owned()),
        }),
        agent(7),
        node(70),
    );

    let (project_id, agent_id, node_id, conversation_id) = state
        .structured_sessions
        .meta_for_session(&sid)
        .expect("live structured session metadata");
    assert_eq!(agent_id, agent(7));
    assert_eq!(node_id, node(70));
    assert_eq!(conversation_id.as_deref(), Some("engine-main"));

    let resets_at_ms = i64::MAX;
    state.session_limit_service.on_rate_limited(
        project_id,
        agent_id,
        node_id,
        conversation_id,
        Some(resets_at_ms),
    );

    let mut saw_rate_limited = false;
    let mut saw_scheduled = false;
    for _ in 0..32 {
        match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
            Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
                saw_rate_limited = true;
            }
            Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
                saw_scheduled = true;
                break;
            }
            Ok(Ok(_)) => continue,
            _ => break,
        }
    }

    assert!(
        saw_rate_limited,
        "AgentRateLimited emitted for direct agent"
    );
    assert!(
        saw_scheduled,
        "AgentResumeScheduled emitted for direct agent"
    );
    assert!(state.session_limit_service.cancel_resume(agent(7)));
}

/// B1/B2 direct : si le moteur donne une heure de reset mais que la session vivante
/// ne porte pas de `conversation_id`, la commande doit normaliser vers le fallback
/// humain plutôt que d'armer une reprise impossible à reprendre.
#[tokio::test]
async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() {
    let state = AppState::build(temp_path("appdata"));
    let mut rx = state.event_bus.raw_receiver();
    let sid = session(708);
    state.structured_sessions.insert(
        Arc::new(LiveStructuredSession {
            id: sid,
            conversation_id: None,
        }),
        agent(7),
        node(70),
    );

    let (project_id, agent_id, node_id, conversation_id) = state
        .structured_sessions
        .meta_for_session(&sid)
        .expect("live structured session metadata");
    assert_eq!(conversation_id, None);

    let resets_at_ms = Some(i64::MAX);
    let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
        (Some(conversation_id), resets_at_ms) => (Some(conversation_id), resets_at_ms),
        (None, None) => (None, None),
        (None, Some(_)) => (None, None),
    };
    state.session_limit_service.on_rate_limited(
        project_id,
        agent_id,
        node_id,
        conversation_id,
        resets_at_ms,
    );

    let mut saw_rate_limited_none = false;
    let mut saw_suspected = false;
    let mut saw_scheduled = false;
    for _ in 0..32 {
        match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
            Ok(Ok(DomainEvent::AgentRateLimited {
                agent_id,
                resets_at_ms: None,
            })) if agent_id == agent(7) => saw_rate_limited_none = true,
            Ok(Ok(DomainEvent::AgentRateLimitSuspected { agent_id, .. }))
                if agent_id == agent(7) =>
            {
                saw_suspected = true;
            }
            Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
                saw_scheduled = true;
            }
            Ok(Ok(_)) => continue,
            _ => break,
        }
    }

    assert!(
        saw_rate_limited_none,
        "fallback exposes AgentRateLimited(None)"
    );
    assert!(saw_suspected, "fallback exposes human input state");
    assert!(
        !saw_scheduled,
        "no AgentResumeScheduled without engine conversation_id"
    );
    assert!(!state.session_limit_service.cancel_resume(agent(7)));
}

/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries
/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).
///
/// La commande `#[tauri::command]` exige une `State<AppState>` non constructible hors
/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState`
/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément
/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428).
#[tokio::test]
async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
    let state = AppState::build(temp_path("appdata"));
    let unknown = agent(99);

    assert!(
        state.structured_sessions.node_for_agent(&unknown).is_none(),
        "aucune session structurée vivante ⇒ pas de cellule"
    );
    assert!(
        state.terminal_sessions.node_for_agent(&unknown).is_none(),
        "aucune session terminal vivante ⇒ pas de cellule"
    );
    // Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise :
    // la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin.
    let resolved = state
        .structured_sessions
        .node_for_agent(&unknown)
        .or_else(|| state.terminal_sessions.node_for_agent(&unknown));
    assert!(
        resolved.is_none(),
        "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"
    );
}

/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited`
/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`.
#[tokio::test]
async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
    let state = AppState::build(temp_path("appdata"));
    let mut rx = state.event_bus.raw_receiver();

    // Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
    let resets_at_ms = i64::MAX;
    state.session_limit_service.confirm_human_resume(
        project_id(1),
        agent(8),
        node(80),
        None,
        resets_at_ms,
    );

    let mut saw_rate_limited = false;
    let mut saw_scheduled = false;
    for _ in 0..32 {
        match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
            Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => {
                saw_rate_limited = true;
            }
            Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => {
                saw_scheduled = true;
                break;
            }
            Ok(Ok(_)) => continue,
            _ => break,
        }
    }
    assert!(
        saw_rate_limited,
        "AgentRateLimited relayed on the bus (humain)"
    );
    assert!(
        saw_scheduled,
        "AgentResumeScheduled relayed on the bus (humain)"
    );

    // Annulable par la même voie que l'auto.
    assert!(
        state.session_limit_service.cancel_resume(agent(8)),
        "an armed human resume is cancellable"
    );
}
