//! [`HeuristicHandoffSummarizer`] — l'adapter zéro-dépendance du port
//! [`HandoffSummarizer`] (cadrage « persistance conversationnelle », lot P4).
//!
//! Replie un [`Handoff`] de façon **incrémentale**, **déterministe**, **sans I/O** et
//! **sans modèle** (ARCHITECTURE §19.2/§19.3, ligne P4 du §19.6). C'est le repli
//! universel zéro-dépendance ; un `LlmHandoffSummarizer` plus riche viendra le substituer
//! en P10 (OCP — le port async est figé pour ça, cf. [`HandoffSummarizer`]).
//!
//! ## Stratégie heuristique
//!
//! Le résumé garde deux choses, en ne lisant que **l'incrément** (`new_turns`) — jamais
//! tout le fil depuis zéro :
//!
//! 1. **Un objectif courant** ([`Handoff::objective`]) : repris **tel quel** de `prev`.
//!    Si `prev` n'en a pas (ou est `None`), on en **extrait** un depuis le **premier
//!    tour `Prompt`** de l'incrément (sa première ligne non vide, tronquée) — règle simple
//!    et déterministe : le premier prompt d'un fil énonce typiquement la tâche. Une fois
//!    fixé, l'objectif ne change plus (on ne le réécrit pas à chaque tour).
//!
//! 2. **Une fenêtre des [`WINDOW`] derniers tours** rendue en Markdown. Comme on ne
//!    dispose pas de tout le fil dans `fold`, on **reconstitue** cette fenêtre à partir des
//!    tours déjà rendus dans `prev.summary_md` (reparsés) **concaténés** à `new_turns`,
//!    puis on **tronque** aux [`WINDOW`] derniers. La borne est donc toujours respectée,
//!    même après de nombreux replis successifs.
//!
//! Le `summary_md` résultant = (ligne d'objectif si présent) + les [`WINDOW`] derniers
//! tours formatés. Format d'un tour stable et reparsable (cf. [`render_turn`]).
//!
//! ## Curseur (`up_to`)
//!
//! - `new_turns` non vide ⇒ l'id du **dernier** tour de l'incrément.
//! - `new_turns` vide ⇒ on renvoie `prev` **inchangé** (rien de neuf à intégrer).
//! - `prev = None` et `new_turns` vide ⇒ handoff vide, curseur = **nil UUID**
//!   ([`TurnId::from_uuid(Uuid::nil())`]) : sentinelle sûre « aucun tour couvert ».
//!
//! ## Déterminisme
//!
//! Mêmes entrées ⇒ même sortie (aucune horloge, aucun aléa, aucune I/O), donc trivialement
//! testable.

use async_trait::async_trait;

use domain::conversation_log::{ConversationTurn, Handoff, HandoffSummarizer, TurnId, TurnRole};

/// Nombre maximum de tours conservés dans la fenêtre Markdown du résumé.
///
/// Borne **publique** (et donc lisible par l'agent Test pour vérifier la troncature sans
/// la deviner) : au-delà de `WINDOW` tours, seuls les `WINDOW` derniers sont rendus.
pub const WINDOW: usize = 20;

/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
const OBJECTIVE_MAX_CHARS: usize = 200;

/// Longueur maximale (en caractères) du **texte aplati** d'un tour rendu sur une ligne
/// (lot LS5). C'est le correctif central du trou de perf : sans borne, un gros
/// `Response` produit une ligne géante qui gonfle le `summary_md` (et donc le contexte
/// injecté à chaque lancement). Au-delà, le texte est tronqué avec une élision `…` ; le
/// préfixe `- **role:**` reste **intact** (la ligne reste reparsable). Borne **publique**
/// pour que l'agent Test la vérifie sans la deviner.
pub const TURN_LINE_MAX_CHARS: usize = 240;

/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";

/// Adapter heuristique zéro-dépendance du résumeur de handoff.
///
/// Sans état (aucun champ) : une seule instance sert toutes les conversations. Construit
/// via [`HeuristicHandoffSummarizer::new`] ou [`Default`].
#[derive(Debug, Default, Clone, Copy)]
pub struct HeuristicHandoffSummarizer;

impl HeuristicHandoffSummarizer {
    /// Construit le résumeur heuristique (sans état).
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

/// Rend un tour en une ligne Markdown stable et **reparsable** (cf. [`parse_rendered`]).
///
/// Format : `- **<role>:** <texte sur une ligne>`. Le texte est aplati (sauts de ligne →
/// espaces) pour garder une ligne par tour, ce qui rend la fenêtre reconstituable depuis
/// `prev.summary_md`.
fn render_turn(turn: &ConversationTurn) -> String {
    let label = match turn.role {
        TurnRole::Prompt => "Prompt",
        TurnRole::Response => "Response",
        TurnRole::ToolActivity => "Tool",
    };
    // Borne LS5 : le texte aplati est tronqué à `TURN_LINE_MAX_CHARS` (élision `…`) pour
    // éviter qu'un gros Response ne produise une ligne géante. Le préfixe reste intact.
    let flat = elide(&flatten(&turn.text), TURN_LINE_MAX_CHARS);
    format!("- **{label}:** {flat}")
}

/// Aplati un texte multi-lignes en une seule ligne (sauts de ligne → espace, trim).
fn flatten(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Tronque `text` à `max_chars` caractères (char-boundary safe), en ajoutant une
/// élision `…` quand il a effectivement été coupé. En deçà, renvoie le texte tel quel.
fn elide(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        text.to_owned()
    } else {
        let kept: String = text.chars().take(max_chars).collect();
        format!("{kept}…")
    }
}

/// Reparse les lignes de tours déjà rendues dans un `summary_md` précédent.
///
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
/// les blancs. On garde la **ligne brute** (déjà au bon format) : pas besoin de
/// reconstruire un `ConversationTurn` complet, on ne manipule que du Markdown.
fn parse_rendered(summary_md: &str) -> Vec<String> {
    summary_md
        .lines()
        .filter(|l| l.starts_with("- **"))
        .map(str::to_owned)
        .collect()
}

/// Extrait un objectif candidat du premier tour `Prompt` de l'incrément, le cas échéant.
///
/// Première ligne non vide du premier prompt, aplatie et tronquée à [`OBJECTIVE_MAX_CHARS`].
fn extract_objective(new_turns: &[ConversationTurn]) -> Option<String> {
    let first_prompt = new_turns.iter().find(|t| t.role == TurnRole::Prompt)?;
    let flat = flatten(&first_prompt.text);
    if flat.is_empty() {
        return None;
    }
    let truncated: String = flat.chars().take(OBJECTIVE_MAX_CHARS).collect();
    Some(truncated)
}

/// Assemble le `summary_md` final = ligne d'objectif (si présent) + fenêtre de tours.
fn render_summary(objective: Option<&str>, window: &[String]) -> String {
    let mut blocks: Vec<String> = Vec::new();
    if let Some(obj) = objective {
        blocks.push(format!("{OBJECTIVE_PREFIX}{obj}"));
    }
    if !window.is_empty() {
        blocks.push(window.join("\n"));
    }
    blocks.join("\n\n")
}

#[async_trait]
impl HandoffSummarizer for HeuristicHandoffSummarizer {
    async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
        // Rien de neuf : on rend `prev` inchangé (ou un handoff vide cohérent si None).
        if new_turns.is_empty() {
            return prev.unwrap_or_else(|| {
                Handoff::new(String::new(), TurnId::from_uuid(uuid::Uuid::nil()), None)
            });
        }

        // Objectif : repris de `prev` s'il existe, sinon extrait du premier prompt de
        // l'incrément (règle simple et déterministe ; figé une fois fixé).
        let objective = prev
            .as_ref()
            .and_then(|h| h.objective.clone())
            .or_else(|| extract_objective(new_turns));

        // Fenêtre : (tours déjà rendus dans prev) ++ (incrément rendu), tronquée aux
        // WINDOW derniers. On ne relit jamais tout le log : seul l'incrément est nouveau.
        let mut window: Vec<String> = prev
            .as_ref()
            .map(|h| parse_rendered(&h.summary_md))
            .unwrap_or_default();
        window.extend(new_turns.iter().map(render_turn));
        let start = window.len().saturating_sub(WINDOW);
        let window = &window[start..];

        // Curseur : l'id du dernier tour de l'incrément (new_turns non vide ici).
        let up_to = new_turns
            .last()
            .map(|t| t.id)
            .unwrap_or_else(|| TurnId::from_uuid(uuid::Uuid::nil()));

        let summary_md = render_summary(objective.as_deref(), window);
        Handoff::new(summary_md, up_to, objective)
    }
}
