//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21,
//! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter
//! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y
//! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1).
//!
//! # Frontière T2 respectée
//!
//! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le
//! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la
//! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure).
//!
//! # Robustesse (« solide même pour un novice »)
//!
//! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`]
//! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un
//! profil pourri ne doit pas faire tomber IdeA.
//!
//! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4)
//!
//! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir
//! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le
//! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser
//! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage
//! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`].

use regex::Regex;

use domain::profile::{AgentProfile, RateLimitPattern};
use domain::session_limit::{RateLimitSource, SessionLimit};

use crate::timeparse;

/// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents
/// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil
/// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double-
/// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser.
#[must_use]
pub fn applies(profile: &AgentProfile) -> bool {
    profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some()
}

/// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du
/// champ `time_format` du profil (donnée opaque au domaine, comprise ici).
///
/// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResetTimeFormat {
    /// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601).
    Auto,
    /// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**.
    EpochSeconds,
    /// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**.
    EpochMillis,
    /// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue.
    Iso8601,
    /// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` :
    /// **délai** en secondes ⇒ `now + delta`.
    RelativeSeconds,
    /// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`.
    RelativeMillis,
    /// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale**
    /// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée.
    WallClock,
}

impl ResetTimeFormat {
    /// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent).
    fn from_opt(time_format: Option<&str>) -> Self {
        let Some(raw) = time_format else {
            return Self::Auto;
        };
        match raw.trim().to_ascii_lowercase().as_str() {
            "epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds,
            "epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis,
            "iso8601" | "rfc3339" | "iso" => Self::Iso8601,
            "relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => {
                Self::RelativeSeconds
            }
            "relative_ms" | "relative_millis" => Self::RelativeMillis,
            "wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock,
            _ => Self::Auto,
        }
    }

    /// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à
    /// `now_ms` pour les heures relatives/murales. `None` si la capture est
    /// inexploitable (⇒ détection sans heure ⇒ filet humain en aval).
    fn resolve(self, captured: &str, now_ms: i64) -> Option<i64> {
        let c = captured.trim();
        match self {
            Self::Auto => timeparse::parse_absolute_ms(c),
            Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
            Self::EpochMillis => c.parse::<i64>().ok(),
            Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
            Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
            Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
            Self::WallClock => {
                let (h, m, s) = timeparse::parse_wall_clock(c)?;
                Some(timeparse::wall_clock_to_ms(now_ms, h, m, s))
            }
        }
    }
}

/// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure.
///
/// À instancier **par agent** (au câblage PTY, LS7) à partir de son
/// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via
/// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois**
/// à la construction, pas à chaque fragment.
pub struct RateLimitParser {
    /// Motif compilé recherché dans la sortie.
    regex: Regex,
    /// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset.
    /// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`).
    reset_capture: Option<String>,
    /// Stratégie d'interprétation de la capture, pré-calculée.
    time_format: ResetTimeFormat,
}

impl RateLimitParser {
    /// Compile le motif du profil. `None` si la regex est **invalide** (profil mal
    /// configuré) — robustesse : jamais de panique, l'agent tourne juste sans
    /// détection niveau 2.
    #[must_use]
    pub fn new(pattern: &RateLimitPattern) -> Option<Self> {
        let regex = Regex::new(&pattern.pattern).ok()?;
        Some(Self {
            regex,
            reset_capture: pattern.reset_capture.clone(),
            time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()),
        })
    }

    /// Analyse un fragment de sortie texte.
    ///
    /// - **pas de match** ⇒ `None` (pas de limite) ;
    /// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`,
    ///   `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture
    ///   `reset_capture` interprété selon `time_format` — `None` si pas de capture,
    ///   groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet
    ///   humain en aval, §21).
    #[must_use]
    pub fn detect(&self, text: &str, now_ms: i64) -> Option<SessionLimit> {
        let caps = self.regex.captures(text)?;
        let resets_at_ms = self.reset_capture.as_deref().and_then(|name| {
            // Groupe nommé en priorité ; repli sur un index décimal si `name` en est un.
            let raw = caps
                .name(name)
                .or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
            self.time_format.resolve(raw.as_str(), now_ms)
        });
        Some(SessionLimit::new(
            resets_at_ms,
            now_ms,
            RateLimitSource::Pattern,
        ))
    }
}

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

    use domain::ids::ProfileId;
    use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};

    /// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s.
    const NOW: i64 = 1_700_000_000_000;
    /// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales.
    const DAY_START: i64 = 1_699_920_000_000;

    fn pattern(
        pat: &str,
        reset_capture: Option<&str>,
        time_format: Option<&str>,
    ) -> RateLimitPattern {
        RateLimitPattern::new(
            pat.to_owned(),
            reset_capture.map(str::to_owned),
            time_format.map(str::to_owned),
        )
        .expect("motif valide (non vide)")
    }

    // -- new : robustesse regex ----------------------------------------------

    #[test]
    fn new_returns_none_on_invalid_regex() {
        // Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique).
        let p = pattern("rate limit (", None, None);
        assert!(RateLimitParser::new(&p).is_none());
    }

    // -- detect : pas de match -----------------------------------------------

    #[test]
    fn detect_returns_none_when_pattern_does_not_match() {
        let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
        assert!(parser.detect("tout va bien", NOW).is_none());
    }

    // -- detect : match sans reset_capture -----------------------------------

    #[test]
    fn detect_match_without_reset_capture_has_no_time() {
        let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
        let limit = parser
            .detect("oops: rate limit reached!", NOW)
            .expect("détecté");
        assert_eq!(limit.resets_at_ms, None);
        assert_eq!(limit.detected_at_ms, NOW);
        assert_eq!(limit.source, RateLimitSource::Pattern);
    }

    // -- detect : formats ABSOLUS --------------------------------------------

    #[test]
    fn detect_epoch_seconds_format() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>\d+)",
            Some("reset"),
            Some("epoch_s"),
        ))
        .unwrap();
        let limit = parser
            .detect("limit, resets at 1700000000 ok", NOW)
            .expect("détecté");
        assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
        assert_eq!(limit.source, RateLimitSource::Pattern);
    }

    #[test]
    fn detect_epoch_millis_format() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>\d+)",
            Some("reset"),
            Some("epoch_ms"),
        ))
        .unwrap();
        let limit = parser
            .detect("resets at 1700000000000", NOW)
            .expect("détecté");
        assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
    }

    #[test]
    fn detect_iso8601_format() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>\S+)",
            Some("reset"),
            Some("iso8601"),
        ))
        .unwrap();
        let limit = parser
            .detect("resets at 2023-11-14T22:13:20Z", NOW)
            .expect("détecté");
        assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
    }

    // -- detect : format RELATIF ---------------------------------------------

    #[test]
    fn detect_relative_seconds_format_uses_now() {
        let parser = RateLimitParser::new(&pattern(
            r"retry after (?P<reset>\d+)s",
            Some("reset"),
            Some("relative_s"),
        ))
        .unwrap();
        let limit = parser.detect("retry after 600s", NOW).expect("détecté");
        assert_eq!(limit.resets_at_ms, Some(NOW + 600_000));
        assert_eq!(limit.source, RateLimitSource::Pattern);
    }

    // -- detect : format MURAL (passage de minuit) ---------------------------

    /// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC).
    #[test]
    fn detect_wall_clock_same_day_when_future() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>.+)",
            Some("reset"),
            Some("wall"),
        ))
        .unwrap();
        let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là
        let limit = parser.detect("resets at 3pm", now_10h).expect("détecté");
        // 15:00 le même jour.
        assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000));
    }

    /// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h).
    #[test]
    fn detect_wall_clock_next_day_when_past() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>.+)",
            Some("reset"),
            Some("wall"),
        ))
        .unwrap();
        let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC
        let limit = parser.detect("resets at 3pm", now_16h).expect("détecté");
        // 15:00 le LENDEMAIN.
        assert_eq!(
            limit.resets_at_ms,
            Some(DAY_START + 15 * 3_600_000 + 86_400_000)
        );
    }

    // -- detect : match mais capture inexploitable ⇒ détection sans heure -----

    #[test]
    fn detect_match_with_missing_capture_group_has_no_time() {
        // reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset".
        let parser =
            RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap();
        let limit = parser.detect("rate limited", NOW).expect("détecté");
        assert_eq!(limit.resets_at_ms, None);
        assert_eq!(limit.source, RateLimitSource::Pattern);
    }

    #[test]
    fn detect_match_with_unparsable_value_has_no_time() {
        // Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None.
        let parser = RateLimitParser::new(&pattern(
            r"reset (?P<reset>\w+)",
            Some("reset"),
            Some("epoch_s"),
        ))
        .unwrap();
        let limit = parser.detect("reset abc", NOW).expect("détecté");
        assert_eq!(limit.resets_at_ms, None);
    }

    // -- detect : appelable plusieurs fois (regex compilé une seule fois) -----

    #[test]
    fn detect_can_be_called_multiple_times() {
        let parser = RateLimitParser::new(&pattern(
            r"resets at (?P<reset>\d+)",
            Some("reset"),
            Some("epoch_s"),
        ))
        .unwrap();
        let a = parser
            .detect("resets at 1700000000", NOW)
            .expect("1er détecté");
        assert!(parser.detect("rien ici", NOW).is_none());
        let b = parser
            .detect("resets at 1700000000", NOW)
            .expect("2e détecté");
        assert_eq!(a.resets_at_ms, b.resets_at_ms);
        assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
    }

    // -- applies(profile) : règle anti-double-détection (§21.10-4) ------------

    fn base_profile() -> AgentProfile {
        AgentProfile::new(
            ProfileId::from_uuid(uuid::Uuid::nil()),
            "Dev",
            "claude",
            Vec::new(),
            ContextInjection::convention_file("CLAUDE.md").expect("valide"),
            None,
            "{agentRunDir}",
            None,
        )
        .expect("profil valide")
    }

    #[test]
    fn applies_false_for_structured_profile_even_with_pattern() {
        let profile = base_profile()
            .with_structured_adapter(StructuredAdapter::Claude)
            .with_rate_limit_pattern(pattern("rate limit", None, None));
        assert!(
            !applies(&profile),
            "un agent structuré détecte par le niveau 1"
        );
    }

    #[test]
    fn applies_true_for_pty_profile_with_pattern() {
        let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None));
        assert!(applies(&profile));
    }

    #[test]
    fn applies_false_for_pty_profile_without_pattern() {
        assert!(!applies(&base_profile()));
    }
}
