//! Limite de session/débit d'un agent — value object pur + calcul de reprise
//! (ARCHITECTURE §21).
//!
//! Objet **pur** (aucune I/O, aucun temps réel, aucune dépendance externe) : il
//! capture le **fait neutre** « cet agent est limité, reset à T (peut-être) » et
//! calcule, à partir de l'heure courante, le **plan de reprise** correspondant.
//! Tout savoir spécifique modèle (forme du `rate_limit_event` Claude, regex d'une
//! TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (§21.2).
//!
//! La limite de session est un **3ᵉ axe d'état orthogonal** aux deux axes déjà
//! posés dans [`crate::input`] ([`crate::input::AgentBusyState`] et
//! [`crate::input::AgentLiveness`]) : un agent peut être `Idle`/`Busy`,
//! `Alive`/`Stalled`, **et** limité jusqu'à une certaine heure.
//!
//! # Pourquoi des époche-millisecondes ?
//!
//! Toutes les heures manipulées ici sont des **i64 époche-millisecondes**,
//! homogènes avec [`crate::ports::Clock::now_millis`] et
//! [`crate::input::AgentBusyState`] : sérialisables, comparables, sans dépendance
//! à `std::time::Instant` (monotone, non sérialisable — cf. §21.2-T1).

/// D'où provient la détection de la limite — utile pour la traçabilité et l'UI
/// (afficher « limite structurée » vs « confirmée par l'utilisateur »).
///
/// Calqué sur la hiérarchie de détection à trois niveaux de §21.1.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitSource {
    /// **Niveau 1** : extraite du flux structuré de l'adapter (le plus solide ;
    /// Claude `rate_limit_info.resetsAt`, équivalent Codex…).
    Structured,
    /// **Niveau 2** : détectée par un motif déclaratif de profil
    /// ([`crate::profile::RateLimitPattern`]) sur la sortie PTY d'un agent TUI.
    Pattern,
    /// **Niveau 3** : confirmée par l'utilisateur (filet humain) quand rien n'a
    /// matché automatiquement mais que l'agent semble bloqué.
    Human,
}

/// Value object **pur** : une limite de session/débit détectée pour un agent
/// (ARCHITECTURE §21.3).
///
/// Immuable, sans I/O, trivialement testable. Vit **en mémoire uniquement**
/// (§21.1-3) : aucune persistance, aucun store. Un agent n'a qu'**une** limite
/// vivante à la fois ; un second signal la **rafraîchit** plutôt que de l'empiler
/// (dédoublonnage assuré côté application, §21.10-4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionLimit {
    /// Instant de reset en **époche-millisecondes**. `None` quand aucune heure de
    /// reset exploitable n'a été obtenue ⇒ pas de reprise auto possible (filet
    /// humain, §21.1 niveau 3).
    pub resets_at_ms: Option<i64>,
    /// Instant de **détection** de la limite, en époche-millisecondes (utile à l'UI
    /// et au diagnostic ; non requis pour le calcul de reprise).
    pub detected_at_ms: i64,
    /// Niveau de détection ayant produit cette limite.
    pub source: RateLimitSource,
}

impl SessionLimit {
    /// Construit une limite de session. Pur, sans validation : tous les états
    /// (`resets_at_ms` présent ou absent, n'importe quelle source) sont légitimes.
    #[must_use]
    pub const fn new(
        resets_at_ms: Option<i64>,
        detected_at_ms: i64,
        source: RateLimitSource,
    ) -> Self {
        Self {
            resets_at_ms,
            detected_at_ms,
            source,
        }
    }

    /// Vrai si une heure de reset exploitable est connue (⇒ reprise auto possible).
    /// Faux ⇒ filet humain (l'application demande l'heure à l'utilisateur).
    #[must_use]
    pub const fn has_known_reset(&self) -> bool {
        self.resets_at_ms.is_some()
    }
}

/// Plan de reprise calculé par [`plan_resume`] (ARCHITECTURE §21.3).
///
/// Donnée **pure** : ne porte que ce dont l'application a besoin pour armer la
/// reprise, jamais de closure ni de port (cf. l'esprit du dispatch orchestrateur
/// §14.3 — une **intention** model-agnostique, pas un effet).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResumePlan {
    /// Reprise **automatique programmée** : armer un réveil à `fire_at_ms` qui
    /// relancera l'agent via [`crate::ports::SessionPlan::Resume`].
    Scheduled {
        /// Échéance du réveil en **époche-millisecondes**. Vaut l'heure de reset,
        /// **clampée à `now_ms`** si le reset est déjà passé (on ne programme jamais
        /// une échéance dans le passé : reprise immédiate).
        fire_at_ms: i64,
        /// Identifiant de conversation du moteur à reprendre (pivot model-agnostique,
        /// porté tel quel jusqu'à [`crate::ports::SessionPlan::Resume`]). `None` ⇒
        /// reprise en mode dégradé (sans id), comme le reste du chemin de reprise.
        conversation_id: Option<String>,
    },
    /// Aucune reprise automatique possible : l'heure de reset est inconnue
    /// (`SessionLimit::resets_at_ms == None`) ⇒ **filet humain** (§21.1 niveau 3).
    /// L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle
    /// (jamais d'inaction silencieuse, jamais d'auto sans heure).
    HumanFallback,
}

/// Calcule le **plan de reprise** d'un agent limité, à partir de l'heure courante
/// (ARCHITECTURE §21.3). Fonction **pure** : aucune I/O, aucun temps réel — `now_ms`
/// est injecté (cf. les fonctions pures de `LayoutTree`, §7.2), ce qui la rend
/// trivialement testable.
///
/// - `limit.resets_at_ms == Some(t)` ⇒ [`ResumePlan::Scheduled`] avec
///   `fire_at_ms = max(t, now_ms)` (on ne programme jamais dans le passé : un reset
///   déjà écoulé ⇒ reprise immédiate) et l'`conversation_id` fourni propagé tel quel.
/// - `limit.resets_at_ms == None` ⇒ [`ResumePlan::HumanFallback`] (filet humain).
#[must_use]
pub fn plan_resume(
    now_ms: i64,
    limit: &SessionLimit,
    conversation_id: Option<String>,
) -> ResumePlan {
    match limit.resets_at_ms {
        Some(resets_at_ms) => ResumePlan::Scheduled {
            fire_at_ms: resets_at_ms.max(now_ms),
            conversation_id,
        },
        None => ResumePlan::HumanFallback,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const NOW: i64 = 1_700_000_000_000;

    fn limit(resets_at_ms: Option<i64>) -> SessionLimit {
        SessionLimit::new(resets_at_ms, NOW, RateLimitSource::Structured)
    }

    // -- plan_resume : reset futur ⇒ Scheduled à l'heure de reset ----------------

    #[test]
    fn future_reset_schedules_at_reset_time_with_conversation_id() {
        let reset = NOW + 60_000;
        let plan = plan_resume(NOW, &limit(Some(reset)), Some("conv-42".to_owned()));
        assert_eq!(
            plan,
            ResumePlan::Scheduled {
                fire_at_ms: reset,
                conversation_id: Some("conv-42".to_owned()),
            }
        );
    }

    // -- plan_resume : clamp anti-passé ------------------------------------------

    #[test]
    fn past_reset_is_clamped_to_now_never_in_the_past() {
        let past = NOW - 60_000;
        let plan = plan_resume(NOW, &limit(Some(past)), None);
        match plan {
            ResumePlan::Scheduled { fire_at_ms, .. } => {
                assert_eq!(
                    fire_at_ms, NOW,
                    "un reset déjà passé ⇒ reprise immédiate (now)"
                );
            }
            other => panic!("attendu Scheduled, obtenu {other:?}"),
        }
    }

    #[test]
    fn reset_exactly_now_fires_at_now() {
        let plan = plan_resume(NOW, &limit(Some(NOW)), None);
        assert_eq!(
            plan,
            ResumePlan::Scheduled {
                fire_at_ms: NOW,
                conversation_id: None,
            }
        );
    }

    // -- plan_resume : pas d'heure ⇒ filet humain --------------------------------

    #[test]
    fn unknown_reset_falls_back_to_human() {
        let plan = plan_resume(NOW, &limit(None), Some("conv-42".to_owned()));
        assert_eq!(plan, ResumePlan::HumanFallback);
    }

    // -- conversation_id propagé tel quel (Some et None) -------------------------

    #[test]
    fn conversation_id_some_is_propagated_into_scheduled() {
        let reset = NOW + 1;
        let plan = plan_resume(NOW, &limit(Some(reset)), Some("c".to_owned()));
        assert_eq!(
            plan,
            ResumePlan::Scheduled {
                fire_at_ms: reset,
                conversation_id: Some("c".to_owned()),
            }
        );
    }

    #[test]
    fn conversation_id_none_is_propagated_into_scheduled() {
        let reset = NOW + 1;
        let plan = plan_resume(NOW, &limit(Some(reset)), None);
        assert_eq!(
            plan,
            ResumePlan::Scheduled {
                fire_at_ms: reset,
                conversation_id: None,
            }
        );
    }

    // -- SessionLimit::has_known_reset -------------------------------------------

    #[test]
    fn has_known_reset_is_true_with_some_and_false_with_none() {
        assert!(limit(Some(NOW)).has_known_reset());
        assert!(!limit(None).has_known_reset());
    }
}
