//! Log canonique de conversation (cadrage « persistance conversationnelle », lot P1).
//!
//! Aujourd'hui la continuité d'un fil repose sur le `resumable_id` CLI du provider
//! ([`crate::conversation::Conversation::resumable_id`]) : fragile (perdu si le run
//! est nettoyé) et **non portable** d'un provider à l'autre (un swap Claude→Codex
//! repart de zéro). Pour y remédier, IdeA tient sa **propre mémoire de conversation**,
//! indépendante du provider, dont la **source de vérité durable** est ce **log
//! canonique append-only, par conversation (paire)** (ARCHITECTURE §19, décision
//! D19-1a).
//!
//! Ce module est **pur** (règle de dépendance du domaine) : ni `tokio`, ni `std::fs`,
//! ni I/O. Il possède le value object [`ConversationTurn`] (un tour de conversation),
//! ses newtypes/énumérés ([`TurnId`], [`TurnRole`]) et le port driven
//! [`ConversationLog`]. L'écriture/lecture réelle du `log.jsonl` est une affaire
//! d'infrastructure (l'adapter `FsConversationLog`, lot P2).
//!
//! ## Frontière (D19-2)
//!
//! Ce log est **distinct** de la mémoire durable `.ideai/memory/` (savoir projet
//! stable, peu bruité) et de la live-state (busy, sessions en cours). Le log est
//! **volumineux et bruité** par nature : il ne **pollue jamais** `memory/`.

use serde::{Deserialize, Serialize};

use crate::conversation::ConversationId;
use crate::input::InputSource;
use crate::ports::StoreError;

/// Identifie un [`ConversationTurn`] dans le log d'une conversation.
///
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TurnId(pub uuid::Uuid);

impl TurnId {
    /// Enrobe un [`uuid::Uuid`] existant.
    #[must_use]
    pub const fn from_uuid(id: uuid::Uuid) -> Self {
        Self(id)
    }

    /// Frappe un nouvel identifiant de tour aléatoire.
    #[must_use]
    pub fn new_random() -> Self {
        Self(uuid::Uuid::new_v4())
    }

    /// Renvoie l'[`uuid::Uuid`] interne.
    #[must_use]
    pub const fn as_uuid(&self) -> uuid::Uuid {
        self.0
    }
}

impl std::fmt::Display for TurnId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// Nature d'un tour dans le fil (qui parle / ce qui se passe).
///
/// Sépare l'invite émise vers l'agent ([`TurnRole::Prompt`]), sa réponse
/// ([`TurnRole::Response`]) et l'activité outillée intermédiaire
/// ([`TurnRole::ToolActivity`]) — assez pour rejouer/résumer fidèlement le travail
/// utile sans dépendre du format d'un provider donné (D19-3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TurnRole {
    /// Une invite adressée à l'agent (humaine ou déléguée par un autre agent).
    Prompt,
    /// Une réponse rendue par l'agent (le `result`/`final` d'un tour).
    Response,
    /// Une activité outillée intermédiaire (appel d'outil, trace) au sein d'un tour.
    ToolActivity,
}

/// Un tour de conversation : qui a parlé, quand, dans quel fil, et quoi.
///
/// Value object pur (aucun comportement, aucune I/O), sérialisable : c'est l'unité
/// **append-only** persistée (une ligne JSON par tour dans `log.jsonl`, lot P2). La
/// [`InputSource`] est la *source de vérité* de l'origine (Humain ou Agent), réutilisée
/// telle quelle depuis le domaine de l'entrée médiée.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationTurn {
    /// Identifiant stable de ce tour (frappé à l'`append`) ; sert aussi de curseur.
    pub id: TurnId,
    /// La conversation (paire) à laquelle ce tour appartient.
    pub conversation: ConversationId,
    /// Horodatage (epoch millisecondes) du tour.
    pub at_ms: u64,
    /// L'origine de l'entrée (Humain ou Agent délégant).
    pub source: InputSource,
    /// La nature du tour (invite, réponse, activité outillée).
    pub role: TurnRole,
    /// Le contenu textuel du tour.
    pub text: String,
}

impl ConversationTurn {
    /// Construit un tour à partir de ses composants.
    #[must_use]
    pub fn new(
        id: TurnId,
        conversation: ConversationId,
        at_ms: u64,
        source: InputSource,
        role: TurnRole,
        text: impl Into<String>,
    ) -> Self {
        Self {
            id,
            conversation,
            at_ms,
            source,
            role,
            text: text.into(),
        }
    }
}

/// Le log canonique append-only des conversations, par paire (port driven, lot P1).
///
/// **Source de vérité durable** (D19-1a) : on **ajoute** un tour à chaque checkpoint
/// (fin de tour, D19-5) et on **relit** à la reprise ou pour recalculer le handoff. Le
/// log est gardé **par conversation** : deux fils distincts n'interfèrent jamais.
///
/// `#[async_trait]` et erreur [`StoreError`] comme les autres ports driven de
/// persistance ([`crate::ports::TemplateStore`]…) : injecté en
/// `Arc<dyn ConversationLog>` au composition root, donc gardé object-safe (les ports
/// async dyn-compatibles passent par `async_trait` qui box le futur — cf. note
/// d'en-tête de [`crate::ports`]).
#[async_trait::async_trait]
pub trait ConversationLog: Send + Sync {
    /// Ajoute `turn` à la fin du log canonique de `conversation`.
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
    async fn append(
        &self,
        conversation: ConversationId,
        turn: ConversationTurn,
    ) -> Result<(), StoreError>;

    /// Relit les tours de `conversation`, dans l'ordre d'ajout.
    ///
    /// `since` est un curseur **exclusif** : quand il vaut `Some(id)`, seuls les tours
    /// **postérieurs** au tour `id` sont renvoyés (relecture incrémentale pour le
    /// recalcul de handoff) ; `None` relit tout le fil depuis le début.
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
    async fn read(
        &self,
        conversation: ConversationId,
        since: Option<TurnId>,
    ) -> Result<Vec<ConversationTurn>, StoreError>;

    /// Renvoie les `n` derniers tours de `conversation`, dans l'ordre d'ajout.
    ///
    /// Renvoie moins de `n` éléments si le fil est plus court (et un `Vec` vide pour
    /// `n == 0` ou un fil vide). Sert au résumé incrémental (les N derniers tours).
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
    async fn last(
        &self,
        conversation: ConversationId,
        n: usize,
    ) -> Result<Vec<ConversationTurn>, StoreError>;
}

/// Résumé cumulatif de reprise d'une conversation (handoff, lot P3).
///
/// Value object pur (aucune I/O, aucun comportement) : c'est le **point de reprise**
/// portable d'une conversation (ARCHITECTURE §19.2/§19.3). À la reprise d'un fil — ou
/// au swap d'un provider à l'autre — IdeA injecte ce `summary_md` plutôt que de
/// dépendre du `resumable_id` CLI d'un provider donné. Le champ [`Handoff::up_to`]
/// est le **curseur** ([`TurnId`]) jusqu'auquel le résumé a été calculé : un recalcul
/// incrémental relit le log à partir de ce curseur ([`ConversationLog::read`] avec
/// `since`) pour étendre le résumé aux tours postérieurs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Handoff {
    /// Le résumé cumulatif, en Markdown : ce qu'un agent doit savoir pour reprendre.
    pub summary_md: String,
    /// Le curseur : dernier [`TurnId`] couvert par ce résumé (borne du recalcul).
    pub up_to: TurnId,
    /// L'objectif courant de la conversation, le cas échéant (fil sans but explicite).
    pub objective: Option<String>,
}

impl Handoff {
    /// Construit un handoff à partir de ses composants.
    #[must_use]
    pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
        Self {
            summary_md: summary_md.into(),
            up_to,
            objective,
        }
    }
}

/// Borne dure (en **caractères**) du `summary_md` d'un [`Handoff`] (lot LS5).
///
/// Plafond universel **indépendant du résumeur** : qu'il soit produit par
/// l'heuristique zéro-dépendance ou par un futur résumeur LLM, le résumé injecté dans
/// le contexte d'un agent (et persisté) ne dépasse jamais cette taille. `4096`
/// caractères tiennent largement un objectif + une fenêtre de tours déjà bornés
/// individuellement, tout en gardant le contexte de reprise petit (chemin chaud).
pub const HANDOFF_SUMMARY_MAX_CHARS: usize = 4096;

/// Préfixe d'une **ligne de tour** dans un `summary_md` rendu (cf. le résumeur
/// heuristique d'infra) : une ligne `- **<role>:** <texte>`. Sert ici à séparer le
/// **préambule d'objectif** des **lignes de tours** pour la troncature distillée.
const TURN_LINE_PREFIX: &str = "- **";

/// Borne le `summary_md` d'un [`Handoff`] à `max_chars` caractères, **best-effort et
/// idempotent** (lot LS5).
///
/// - Sous la borne (`summary_md.chars().count() <= max_chars`) ⇒ handoff **inchangé**
///   (coût nul, cas chaud nominal).
/// - Au-dessus ⇒ **troncature distillée** : on garde le **préambule d'objectif** (le
///   1er bloc, avant la première ligne de tour) PUIS le **maximum de lignes de tours
///   les plus récentes** (fin de fenêtre) tenant sous `max_chars`, en **droppant les
///   plus anciennes d'abord**. Si la ligne conservée la plus récente dépasse à elle
///   seule le budget, sa **fin** est tronquée en gardant le préfixe `- **role:**`
///   intact (le résultat reste **toujours reparsable** comme un `summary_md`).
///
/// [`Handoff::up_to`] et [`Handoff::objective`] ne sont **jamais** modifiés. La fonction
/// ne **re-fold jamais**, ne **rejette jamais** (elle ne doit pas pouvoir bloquer un
/// append/checkpoint) et garantit `bound(bound(x)) == bound(x)`.
#[must_use]
pub fn bound_handoff_summary(handoff: Handoff, max_chars: usize) -> Handoff {
    if handoff.summary_md.chars().count() <= max_chars {
        return handoff;
    }
    let summary_md = distill_summary(&handoff.summary_md, max_chars);
    Handoff {
        summary_md,
        up_to: handoff.up_to,
        objective: handoff.objective,
    }
}

/// Troncature distillée d'un `summary_md` au-dessus de la borne (cf.
/// [`bound_handoff_summary`]). Sépare le préambule d'objectif des lignes de tours, garde
/// le préambule puis le suffixe le plus récent de tours tenant sous `max_chars`.
fn distill_summary(summary: &str, max_chars: usize) -> String {
    // Préambule d'objectif = lignes de tête jusqu'à la 1re ligne de tour ; lignes de
    // tours = celles préfixées `- **` (les seules reparsées par le résumeur). Toute
    // ligne non-tour APRÈS la 1re ligne de tour est du bruit inter-tours, droppée.
    let mut objective_block: Vec<&str> = Vec::new();
    let mut turn_lines: Vec<&str> = Vec::new();
    let mut seen_turn = false;
    for line in summary.lines() {
        if line.starts_with(TURN_LINE_PREFIX) {
            seen_turn = true;
            turn_lines.push(line);
        } else if !seen_turn {
            objective_block.push(line);
        }
    }
    while objective_block.last().is_some_and(|l| l.trim().is_empty()) {
        objective_block.pop();
    }
    let objective_md = objective_block.join("\n");
    let objective_len = objective_md.chars().count();
    // Séparateur `\n\n` entre l'objectif et la fenêtre, seulement si les deux existent.
    let separator_len = if objective_md.is_empty() { 0 } else { 2 };
    let budget_for_turns = max_chars.saturating_sub(objective_len + separator_len);

    // Garder les lignes de tours les plus RÉCENTES (fin) tenant sous le budget ; dropper
    // les plus anciennes d'abord. On parcourt depuis la fin et on s'arrête au 1er dépassement.
    let mut kept_rev: Vec<String> = Vec::new();
    let mut used = 0usize;
    for line in turn_lines.iter().rev() {
        let join_cost = usize::from(!kept_rev.is_empty()); // le `\n` de jointure
        let line_len = line.chars().count();
        if used + join_cost + line_len <= budget_for_turns {
            used += join_cost + line_len;
            kept_rev.push((*line).to_owned());
        } else {
            // La ligne la plus récente ne tient pas même seule ⇒ hard-truncate sa fin
            // en gardant le préfixe `- **role:**` intact (reparse toujours valide).
            if kept_rev.is_empty() {
                let truncated = truncate_turn_line_keeping_prefix(line, budget_for_turns);
                if !truncated.is_empty() {
                    kept_rev.push(truncated);
                }
            }
            break;
        }
    }
    kept_rev.reverse();

    let mut out = String::new();
    if !objective_md.is_empty() {
        out.push_str(&objective_md);
        if !kept_rev.is_empty() {
            out.push_str("\n\n");
        }
    }
    if !kept_rev.is_empty() {
        out.push_str(&kept_rev.join("\n"));
    }
    // Clamp final de sûreté : garantit `out.chars().count() <= max_chars` (donc
    // l'idempotence via la garde d'entrée) même sur une entrée pathologique (objectif
    // gigantesque, aucune ligne de tour). Char-boundary safe.
    if out.chars().count() > max_chars {
        out = out.chars().take(max_chars).collect();
    }
    out
}

/// Tronque la **fin** d'une ligne de tour à `budget` caractères en gardant intact le
/// préfixe `- **<role>:** ` pour que la ligne reste reparsable comme un tour.
fn truncate_turn_line_keeping_prefix(line: &str, budget: usize) -> String {
    if line.chars().count() <= budget {
        return line.to_owned();
    }
    // Préfixe = `- **Role:** ` (inclus l'espace) quand présent, sinon a minima `- **`.
    let prefix: &str = match line.find(":** ") {
        Some(pos) => &line[..pos + 4],
        None => TURN_LINE_PREFIX,
    };
    let prefix_len = prefix.chars().count();
    if budget <= prefix_len {
        // Budget trop petit pour le moindre texte : on garde juste le préfixe reparsable.
        return prefix.to_owned();
    }
    let tail: String = line[prefix.len()..]
        .chars()
        .take(budget - prefix_len)
        .collect();
    format!("{prefix}{tail}")
}

/// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3).
///
/// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
/// par conversation (le dernier point de reprise), écrasé à chaque recalcul. La
/// persistance réelle (`handoff.md`) est une affaire d'infrastructure (l'adapter
/// `FsHandoffStore`, lot P3).
///
/// `#[async_trait]` et erreur [`StoreError`] comme le port voisin [`ConversationLog`] :
/// injecté en `Arc<dyn HandoffStore>` au composition root, gardé object-safe.
#[async_trait::async_trait]
pub trait HandoffStore: Send + Sync {
    /// Charge le handoff de `conversation`, ou `None` si aucun n'a encore été écrit.
    ///
    /// L'absence d'un handoff n'est **jamais** une erreur (`Ok(None)`).
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
    async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError>;

    /// Écrit (en écrasant) le handoff de `conversation`.
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
    async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
}

/// Le résumeur incrémental de handoff (port driving, lot P4).
///
/// **Replie** (au sens d'un *fold*) un [`Handoff`] : on part du résumé précédent (s'il
/// existe) et on n'intègre que **l'incrément** de tours nouveaux ([`new_turns`]), sans
/// jamais relire tout le fil — c'est le seul appelant (l'application) qui relit le log à
/// partir de [`Handoff::up_to`] ([`ConversationLog::read`] avec `since`) pour fournir cet
/// incrément.
///
/// ## Pourquoi `async` (OCP, décision tranchée par Main)
///
/// Le port est **async** même si l'implémentation heuristique zéro-dépendance
/// ([`HeuristicHandoffSummarizer`] côté infra) n'`await` rien : on fige **maintenant** le
/// seam pour qu'un futur `LlmHandoffSummarizer` (P10, qui fera de l'I/O réseau async) se
/// **substitue sans modifier ni le trait ni l'application** (open/closed). `#[async_trait]`
/// pour rester object-safe (injecté en `Arc<dyn HandoffSummarizer>` au composition root).
///
/// ## Pas de `Result` (best-effort, D19-6)
///
/// Le repli est **best-effort** : il ne doit **jamais** bloquer la persistance du log
/// canonique (la source de vérité durable). Une heuristique ne peut pas échouer ; un futur
/// adapter LLM qui échouerait renverra simplement le `prev` (ou un repli heuristique) plutôt
/// que de propager une erreur. Le port ne porte donc **aucune** erreur.
///
/// [`new_turns`]: HandoffSummarizer::fold
#[async_trait::async_trait]
pub trait HandoffSummarizer: Send + Sync {
    /// Replie `prev` (s'il existe) avec **uniquement** `new_turns` et renvoie le handoff
    /// étendu.
    ///
    /// `prev` est le dernier point de reprise connu (ou `None` pour un premier calcul) ;
    /// `new_turns` est l'incrément des tours postérieurs à `prev.up_to`, dans l'ordre
    /// d'ajout. Le résultat couvre jusqu'au dernier tour vu (cf. [`Handoff::up_to`]).
    /// Best-effort : ne renvoie jamais d'erreur (cf. note du trait).
    async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff;
}

/// Le store des `resumable_id` CLI, rangés **par (conversation, provider)** (port
/// driven, lot P5).
///
/// Le `resumable_id` est l'identifiant **propre au moteur** (le `--resume`/`--continue`
/// d'une CLI : `"claude"`, `"codex"`…) qui permet de **réattacher** la session native du
/// provider à la reprise (ARCHITECTURE §19.2/§19.3). Contrairement au [`Handoff`]
/// (résumé **portable**, indépendant du provider, source de vérité de la continuité),
/// ce store garde l'optimisation **non portable** : chaque provider a **son** id, et
/// plusieurs providers peuvent coexister pour une même conversation (après un swap
/// Claude→Codex, on garde l'id de chacun). La clé est donc bien le couple
/// `(conversation, provider_id)`.
///
/// `#[async_trait]` et erreur [`StoreError`] comme les ports voisins
/// ([`ConversationLog`], [`HandoffStore`]) : injecté en `Arc<dyn ProviderSessionStore>`
/// au composition root, gardé object-safe. La persistance réelle (`providers.json`) est
/// une affaire d'infrastructure (l'adapter `FsProviderSessionStore`, lot P5).
#[async_trait::async_trait]
pub trait ProviderSessionStore: Send + Sync {
    /// Charge le `resumable_id` du provider `provider_id` pour `conversation`.
    ///
    /// Renvoie `Ok(Some(id))` si un id a été rangé pour ce couple, `Ok(None)` si le
    /// provider (ou la conversation) n'a encore rien rangé. L'absence n'est **jamais**
    /// une erreur.
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
    async fn get(
        &self,
        conversation: ConversationId,
        provider_id: &str,
    ) -> Result<Option<String>, StoreError>;

    /// Range (en écrasant) le `resumable_id` du provider `provider_id` pour
    /// `conversation`.
    ///
    /// Les autres providers déjà rangés pour la même conversation **coexistent** : seul
    /// l'enregistrement de `provider_id` est créé/écrasé.
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
    async fn set(
        &self,
        conversation: ConversationId,
        provider_id: &str,
        resumable_id: &str,
    ) -> Result<(), StoreError>;
}

// =====================================================================================
// Rotation & pagination du log (lot LS6)
// =====================================================================================
//
// Le `log.jsonl` actif est **borné** par rotation : au-delà d'un seuil (tours OU octets)
// la **tête froide** est déplacée dans des **segments d'archive** (`log.<k>.jsonl`), pour
// que le segment actif (et donc tout `read`/`last`/append) reste petit. La lecture
// **humaine** paginée traverse archives + actif.
//
// ## Invariant pivot (INV-LS6)
//
// La rotation ne déplace/élague **JAMAIS** un tour d'id ≥ `up_to` du handoff courant : le
// tour `up_to` et tous ses postérieurs restent dans le segment **actif**. Sans handoff (ou
// `up_to` = sentinelle nil de LS5) ⇒ **aucune** rotation. C'est ce qui garantit que
// [`ConversationLog::read`] avec `since = up_to` (fold incrémental) et la reprise restent
// corrects après rotation.

/// Seuil de rotation en **nombre de tours** du segment actif : au-delà, la tête froide
/// est archivée (lot LS6).
pub const ROTATE_AFTER_TURNS: usize = 500;

/// Seuil de rotation en **octets** du segment actif (1 MiB) : au-delà, la tête froide est
/// archivée (lot LS6). L'un OU l'autre seuil déclenche.
pub const ROTATE_AFTER_BYTES: u64 = 1024 * 1024;

/// Nombre maximum de **segments d'archive** conservés par conversation (lot LS6). Au-delà,
/// le segment d'archive **le plus ancien** est supprimé (backstop) ; jamais l'actif,
/// jamais un segment contenant un tour ≥ `up_to` (les archives n'en contiennent jamais).
pub const MAX_ARCHIVE_SEGMENTS: usize = 20;

/// Taille de page par défaut de la lecture humaine paginée (lot LS6), appliquée quand
/// l'appelant ne précise pas de limite (`limit == 0`).
pub const PAGE_DEFAULT_LIMIT: usize = 50;

/// Borne **dure** de la taille d'une page (lot LS6) : toute limite est clampée à au plus
/// cette valeur (anti-dump, la lecture humaine reste paginée).
pub const PAGE_MAX_LIMIT: usize = 200;

/// Seuils de rotation passés à [`rotation_plan`] (lot LS6). Regroupés pour garder la
/// fonction pure simple et testable avec des seuils ad hoc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RotationThresholds {
    /// Seuil en nombre de tours du segment actif (cf. [`ROTATE_AFTER_TURNS`]).
    pub max_turns: usize,
    /// Seuil en octets du segment actif (cf. [`ROTATE_AFTER_BYTES`]).
    pub max_bytes: u64,
}

impl RotationThresholds {
    /// Les seuils par défaut du projet ([`ROTATE_AFTER_TURNS`] / [`ROTATE_AFTER_BYTES`]).
    #[must_use]
    pub const fn defaults() -> Self {
        Self {
            max_turns: ROTATE_AFTER_TURNS,
            max_bytes: ROTATE_AFTER_BYTES,
        }
    }
}

/// Statistiques **bon marché** du segment actif d'une conversation (lot LS6), servant de
/// **déclencheur** de rotation sans relire/élaguer quoi que ce soit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentStats {
    /// Nombre de tours (lignes valides) du segment actif.
    pub active_turns: usize,
    /// Taille en octets du segment actif.
    pub active_bytes: u64,
}

/// Sens de pagination d'une page humaine (lot LS6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageDirection {
    /// Vers les tours **plus récents** (postérieurs à l'ancre).
    Forward,
    /// Vers les tours **plus anciens** (antérieurs à l'ancre).
    Backward,
}

/// Curseur d'une lecture paginée (lot LS6).
///
/// `anchor = None` ⇒ on part d'un **bout** du fil : `Forward` ⇒ le tout début (plus
/// anciens), `Backward` ⇒ la toute fin (plus récents). `anchor = Some(id)` ⇒ on pagine
/// **strictement** autour de ce tour, dans la `direction` donnée.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageCursor {
    /// Le tour pivot, ou `None` pour partir d'un bout du fil.
    pub anchor: Option<TurnId>,
    /// Le sens de progression.
    pub direction: PageDirection,
}

/// Une tranche paginée de tours (lot LS6), **toujours en ordre chronologique croissant**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnSlice {
    /// Les tours de la page, du plus ancien au plus récent.
    pub turns: Vec<ConversationTurn>,
    /// `true` s'il existe d'autres tours **dans le sens de progression** au-delà de la page.
    pub has_more: bool,
}

/// La décision **pure** de rotation calculée par [`rotation_plan`] (lot LS6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationDecision {
    /// Ne rien faire (sous les seuils, ou pas de plancher `up_to`).
    Skip,
    /// Archiver la tête froide, en **gardant** tout à partir de `keep_from` dans l'actif.
    Archive {
        /// Le plancher : premier tour conservé dans l'actif (= `up_to` du handoff). Tous
        /// les tours **strictement antérieurs** sont archivés ; `keep_from` et ses
        /// postérieurs restent actifs (INV-LS6).
        keep_from: TurnId,
    },
}

/// Décide **purement** s'il faut archiver la tête froide du segment actif (lot LS6).
///
/// - `floor = None` ⇒ [`RotationDecision::Skip`] : sans plancher `up_to` (pas de handoff),
///   on ne rote jamais (INV-LS6). De même si `floor` est la **sentinelle nil** de LS5
///   (`up_to` d'un handoff vide) : rien à garder en aval de façon fiable ⇒ `Skip`.
/// - Sous **les deux** seuils ⇒ `Skip`.
/// - Au-dessus de **l'un** des seuils ⇒ [`RotationDecision::Archive`] avec
///   `keep_from = floor` : `keep_from` n'est **jamais** antérieur à `up_to` (il **est**
///   `up_to`), donc l'invariant pivot est respecté par construction.
///
/// Pure et idempotente (mêmes entrées ⇒ même sortie ; aucune I/O).
#[must_use]
pub fn rotation_plan(
    active_turns: usize,
    active_bytes: u64,
    floor: Option<TurnId>,
    thresholds: RotationThresholds,
) -> RotationDecision {
    // Pas de plancher, ou plancher = sentinelle nil (handoff vide LS5) ⇒ jamais de rotation.
    let Some(keep_from) = floor else {
        return RotationDecision::Skip;
    };
    if keep_from.as_uuid().is_nil() {
        return RotationDecision::Skip;
    }
    let over_turns = active_turns > thresholds.max_turns;
    let over_bytes = active_bytes > thresholds.max_bytes;
    if over_turns || over_bytes {
        RotationDecision::Archive { keep_from }
    } else {
        RotationDecision::Skip
    }
}

/// Clampe une taille de page demandée dans `[1, PAGE_MAX_LIMIT]` (lot LS6), en appliquant
/// [`PAGE_DEFAULT_LIMIT`] quand l'appelant ne précise rien (`limit == 0`). Pure/testable.
#[must_use]
pub fn clamp_page_limit(limit: usize) -> usize {
    let limit = if limit == 0 {
        PAGE_DEFAULT_LIMIT
    } else {
        limit
    };
    limit.clamp(1, PAGE_MAX_LIMIT)
}

/// Le port d'**archivage & pagination** du log d'une conversation (port driven, lot LS6).
///
/// Complète [`ConversationLog`] (append/read/last, **inchangé**) sans le modifier : la
/// rotation est une opération **hors chemin chaud** (jamais déclenchée par un `append`) et
/// la pagination est une lecture **humaine** archive-aware. Implémenté par le **même**
/// adapter FS que [`ConversationLog`] (il détient déjà les fichiers).
///
/// `#[async_trait]` + erreur [`StoreError`] comme les ports voisins ; injecté en
/// `Arc<dyn ConversationArchive>` au composition root, gardé object-safe.
#[async_trait::async_trait]
pub trait ConversationArchive: Send + Sync {
    /// Statistiques bon marché du segment actif (déclencheur de rotation).
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture.
    async fn stats(&self, conversation: ConversationId) -> Result<SegmentStats, StoreError>;

    /// Archive la tête froide en **gardant** tout à partir de `keep_from` dans l'actif
    /// (INV-LS6). Idempotente ; ne perd jamais de tour ; n'archive jamais un tour
    /// `≥ keep_from`. No-op si `keep_from` est absent de l'actif (déjà roté/inconnu).
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec d'I/O.
    async fn rotate(
        &self,
        conversation: ConversationId,
        keep_from: TurnId,
    ) -> Result<(), StoreError>;

    /// Lit une **page humaine** (archive-aware), toujours en ordre chronologique croissant,
    /// selon le `cursor` et la `limit` (clampée `[1, PAGE_MAX_LIMIT]`).
    ///
    /// # Errors
    /// [`StoreError`] en cas d'échec de lecture.
    async fn page(
        &self,
        conversation: ConversationId,
        cursor: PageCursor,
        limit: usize,
    ) -> Result<TurnSlice, StoreError>;
}

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

    use std::collections::HashMap;
    use std::sync::Mutex;

    // ---------------------------------------------------------------------
    // Deterministic constructors (calqués sur conversation.rs / mailbox.rs)
    // ---------------------------------------------------------------------

    fn conv_id(n: u128) -> ConversationId {
        ConversationId::from_uuid(uuid::Uuid::from_u128(n))
    }

    fn turn_id(n: u128) -> TurnId {
        TurnId::from_uuid(uuid::Uuid::from_u128(n))
    }

    fn turn(conv: ConversationId, id: TurnId, role: TurnRole, text: &str) -> ConversationTurn {
        ConversationTurn::new(id, conv, 1_000, InputSource::Human, role, text)
    }

    fn texts(turns: &[ConversationTurn]) -> Vec<String> {
        turns.iter().map(|t| t.text.clone()).collect()
    }

    // ---------------------------------------------------------------------
    // In-memory double of the port (the normal way to lock a pure port's
    // contract before the Fs adapter lands in P2). Vec-backed, per conversation.
    //
    // The trait is async but the double's state is purely in-memory: the sync
    // Mutex is locked briefly and **never** held across an `.await` point.
    // ---------------------------------------------------------------------

    #[derive(Default)]
    struct InMemoryConversationLog {
        threads: Mutex<HashMap<ConversationId, Vec<ConversationTurn>>>,
    }

    #[async_trait::async_trait]
    impl ConversationLog for InMemoryConversationLog {
        async fn append(
            &self,
            conversation: ConversationId,
            turn: ConversationTurn,
        ) -> Result<(), StoreError> {
            self.threads
                .lock()
                .unwrap()
                .entry(conversation)
                .or_default()
                .push(turn);
            Ok(())
        }

        async fn read(
            &self,
            conversation: ConversationId,
            since: Option<TurnId>,
        ) -> Result<Vec<ConversationTurn>, StoreError> {
            let guard = self.threads.lock().unwrap();
            let Some(thread) = guard.get(&conversation) else {
                return Ok(Vec::new());
            };
            let out = match since {
                None => thread.clone(),
                Some(cursor) => {
                    // Strictly-after semantics: everything past the cursor's position.
                    match thread.iter().position(|t| t.id == cursor) {
                        Some(idx) => thread[idx + 1..].to_vec(),
                        None => Vec::new(),
                    }
                }
            };
            Ok(out)
        }

        async fn last(
            &self,
            conversation: ConversationId,
            n: usize,
        ) -> Result<Vec<ConversationTurn>, StoreError> {
            let guard = self.threads.lock().unwrap();
            let Some(thread) = guard.get(&conversation) else {
                return Ok(Vec::new());
            };
            let start = thread.len().saturating_sub(n);
            Ok(thread[start..].to_vec())
        }
    }

    // ---------------------------------------------------------------------
    // Serde round-trips (verrouille camelCase / transparent)
    // ---------------------------------------------------------------------

    #[test]
    fn turn_id_serializes_transparently() {
        let u = uuid::Uuid::from_u128(7);
        let id = TurnId::from_uuid(u);
        let json = serde_json::to_string(&id).unwrap();
        // `#[serde(transparent)]` => bare quoted uuid, no wrapper object.
        assert_eq!(json, format!("\"{u}\""));
        let back: TurnId = serde_json::from_str(&json).unwrap();
        assert_eq!(back, id);
    }

    #[test]
    fn turn_role_serializes_camel_case() {
        assert_eq!(
            serde_json::to_string(&TurnRole::Prompt).unwrap(),
            "\"prompt\""
        );
        assert_eq!(
            serde_json::to_string(&TurnRole::Response).unwrap(),
            "\"response\""
        );
        assert_eq!(
            serde_json::to_string(&TurnRole::ToolActivity).unwrap(),
            "\"toolActivity\""
        );
        for role in [TurnRole::Prompt, TurnRole::Response, TurnRole::ToolActivity] {
            let json = serde_json::to_string(&role).unwrap();
            let back: TurnRole = serde_json::from_str(&json).unwrap();
            assert_eq!(back, role);
        }
    }

    #[test]
    fn conversation_turn_round_trips_in_camel_case() {
        let t = ConversationTurn::new(
            turn_id(3),
            conv_id(9),
            1_700_000_000_123,
            InputSource::Human,
            TurnRole::Prompt,
            "hello",
        );
        let json = serde_json::to_string(&t).unwrap();
        // camelCase field renaming on the struct.
        assert!(json.contains("\"atMs\":1700000000123"), "got: {json}");
        assert!(!json.contains("at_ms"), "snake_case leaked: {json}");
        let back: ConversationTurn = serde_json::from_str(&json).unwrap();
        assert_eq!(back, t);
    }

    #[test]
    fn conversation_turn_round_trips_agent_source() {
        let from = crate::ids::AgentId::from_uuid(uuid::Uuid::from_u128(11));
        let t = ConversationTurn::new(
            turn_id(4),
            conv_id(9),
            42,
            InputSource::agent(from),
            TurnRole::ToolActivity,
            "ran tool",
        );
        let json = serde_json::to_string(&t).unwrap();
        let back: ConversationTurn = serde_json::from_str(&json).unwrap();
        assert_eq!(back, t);
        assert_eq!(back.source.as_agent(), Some(from));
    }

    // ---------------------------------------------------------------------
    // Port contract — via the in-memory double
    // ---------------------------------------------------------------------

    // ---------------------------------------------------------------------
    // bound_handoff_summary (lot LS5) — borne universelle du summary_md
    // ---------------------------------------------------------------------

    fn handoff(summary: &str) -> Handoff {
        Handoff::new(summary, turn_id(1), Some("garder l'objectif".to_owned()))
    }

    #[test]
    fn bound_under_limit_is_unchanged() {
        let h = handoff("**Objectif :** x\n\n- **Prompt:** a\n- **Response:** b");
        let bounded = bound_handoff_summary(h.clone(), HANDOFF_SUMMARY_MAX_CHARS);
        assert_eq!(bounded, h, "sous la borne ⇒ inchangé (coût nul)");
    }

    #[test]
    fn bound_preserves_objective_and_cursor_and_drops_oldest_turns() {
        // 10 lignes de tours ; borne minuscule ⇒ on garde l'objectif + le suffixe récent.
        let mut s = String::from("**Objectif :** garder ceci");
        for i in 0..10 {
            s.push_str(&format!("\n\n"));
            s.push_str(&format!("- **Prompt:** tour-{i}"));
        }
        // Reconstruire au bon format (objectif + lignes jointes par \n).
        let summary = {
            let mut out = String::from("**Objectif :** garder ceci\n\n");
            let lines: Vec<String> = (0..10).map(|i| format!("- **Prompt:** tour-{i}")).collect();
            out.push_str(&lines.join("\n"));
            out
        };
        let h = Handoff::new(summary, turn_id(7), Some("garder ceci".to_owned()));
        let bounded = bound_handoff_summary(h.clone(), 60);

        assert!(bounded.summary_md.chars().count() <= 60);
        // Objectif + curseur jamais modifiés.
        assert_eq!(bounded.objective, h.objective);
        assert_eq!(bounded.up_to, h.up_to);
        assert!(bounded.summary_md.contains("**Objectif :** garder ceci"));
        // Les plus récents survivent, les plus anciens sont droppés.
        assert!(
            bounded.summary_md.contains("tour-9"),
            "le plus récent reste"
        );
        assert!(
            !bounded.summary_md.contains("tour-0"),
            "le plus ancien droppé"
        );
        // Chaque ligne de tour conservée reste reparsable.
        for line in bounded.summary_md.lines().filter(|l| l.starts_with("- **")) {
            assert!(line.starts_with("- **"));
        }
    }

    #[test]
    fn bound_hard_truncates_a_single_oversize_line_keeping_prefix() {
        let long = "x".repeat(500);
        let summary = format!("- **Response:** {long}");
        let h = Handoff::new(summary, turn_id(3), None);
        let bounded = bound_handoff_summary(h, 60);
        assert!(bounded.summary_md.chars().count() <= 60);
        assert!(
            bounded.summary_md.starts_with("- **Response:** "),
            "préfixe de tour intact ⇒ reparse valide : {}",
            bounded.summary_md
        );
    }

    #[test]
    fn bound_is_idempotent() {
        let summary = {
            let mut out = String::from("**Objectif :** garder ceci\n\n");
            let lines: Vec<String> = (0..30)
                .map(|i| {
                    format!(
                        "- **Prompt:** un tour relativement long numero {i} {}",
                        "y".repeat(50)
                    )
                })
                .collect();
            out.push_str(&lines.join("\n"));
            out
        };
        let h = Handoff::new(summary, turn_id(9), Some("garder ceci".to_owned()));
        let once = bound_handoff_summary(h, 200);
        let twice = bound_handoff_summary(once.clone(), 200);
        assert_eq!(once, twice, "bound(bound(x)) == bound(x)");
        assert!(once.summary_md.chars().count() <= 200);
    }

    // ---------------------------------------------------------------------
    // rotation_plan + clamp_page_limit (lot LS6) — fonctions pures
    // ---------------------------------------------------------------------

    #[test]
    fn rotation_plan_skips_without_floor() {
        // Largement au-dessus des seuils mais pas de plancher ⇒ jamais de rotation.
        let d = rotation_plan(10_000, 10 << 20, None, RotationThresholds::defaults());
        assert_eq!(d, RotationDecision::Skip);
    }

    #[test]
    fn rotation_plan_skips_with_nil_sentinel_floor() {
        let nil = TurnId::from_uuid(uuid::Uuid::nil());
        let d = rotation_plan(10_000, 10 << 20, Some(nil), RotationThresholds::defaults());
        assert_eq!(
            d,
            RotationDecision::Skip,
            "sentinelle nil ⇒ pas de rotation"
        );
    }

    #[test]
    fn rotation_plan_skips_under_both_thresholds() {
        let floor = turn_id(7);
        let d = rotation_plan(10, 1_000, Some(floor), RotationThresholds::defaults());
        assert_eq!(d, RotationDecision::Skip);
    }

    #[test]
    fn rotation_plan_archives_over_either_threshold_keeping_floor() {
        let floor = turn_id(7);
        // Au-dessus du seuil de tours seulement.
        assert_eq!(
            rotation_plan(
                ROTATE_AFTER_TURNS + 1,
                0,
                Some(floor),
                RotationThresholds::defaults()
            ),
            RotationDecision::Archive { keep_from: floor }
        );
        // Au-dessus du seuil d'octets seulement.
        assert_eq!(
            rotation_plan(
                0,
                ROTATE_AFTER_BYTES + 1,
                Some(floor),
                RotationThresholds::defaults()
            ),
            RotationDecision::Archive { keep_from: floor }
        );
    }

    #[test]
    fn rotation_plan_is_idempotent() {
        let floor = turn_id(7);
        let inputs = (ROTATE_AFTER_TURNS + 5, ROTATE_AFTER_BYTES + 5);
        let once = rotation_plan(
            inputs.0,
            inputs.1,
            Some(floor),
            RotationThresholds::defaults(),
        );
        let twice = rotation_plan(
            inputs.0,
            inputs.1,
            Some(floor),
            RotationThresholds::defaults(),
        );
        assert_eq!(once, twice);
    }

    #[test]
    fn clamp_page_limit_applies_default_and_bounds() {
        assert_eq!(clamp_page_limit(0), PAGE_DEFAULT_LIMIT, "0 ⇒ défaut");
        assert_eq!(clamp_page_limit(1), 1);
        assert_eq!(clamp_page_limit(50), 50);
        assert_eq!(clamp_page_limit(10_000), PAGE_MAX_LIMIT, "clampé au max");
    }

    #[tokio::test]
    async fn append_then_read_all_preserves_insertion_order() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
            .await
            .unwrap();
        log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
            .await
            .unwrap();
        log.append(c, turn(c, turn_id(3), TurnRole::Prompt, "c"))
            .await
            .unwrap();

        let all = log.read(c, None).await.unwrap();
        assert_eq!(texts(&all), vec!["a", "b", "c"]);
    }

    #[tokio::test]
    async fn read_with_cursor_is_strictly_exclusive() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        for (id, txt) in [(1, "a"), (2, "b"), (3, "c")] {
            log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
                .await
                .unwrap();
        }
        // since = id of "a" => only the strictly-posterior tours, "a" excluded.
        let after_a = log.read(c, Some(turn_id(1))).await.unwrap();
        assert_eq!(texts(&after_a), vec!["b", "c"]);

        let after_b = log.read(c, Some(turn_id(2))).await.unwrap();
        assert_eq!(texts(&after_b), vec!["c"]);
    }

    #[tokio::test]
    async fn read_cursor_on_last_id_yields_empty() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
            .await
            .unwrap();
        log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
            .await
            .unwrap();

        let after_last = log.read(c, Some(turn_id(2))).await.unwrap();
        assert!(after_last.is_empty(), "cursor on last id => nothing after");
    }

    #[tokio::test]
    async fn last_zero_is_empty() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
            .await
            .unwrap();
        assert!(log.last(c, 0).await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn last_on_empty_thread_is_empty() {
        let log = InMemoryConversationLog::default();
        // Never appended to this conversation.
        assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
        assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn last_n_greater_than_len_returns_everything() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        for (id, txt) in [(1, "a"), (2, "b")] {
            log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
                .await
                .unwrap();
        }
        let last = log.last(c, 10).await.unwrap();
        assert_eq!(texts(&last), vec!["a", "b"]);
    }

    #[tokio::test]
    async fn last_n_less_than_len_returns_the_n_last_in_order() {
        let log = InMemoryConversationLog::default();
        let c = conv_id(1);
        for (id, txt) in [(1, "a"), (2, "b"), (3, "c"), (4, "d")] {
            log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
                .await
                .unwrap();
        }
        let last2 = log.last(c, 2).await.unwrap();
        assert_eq!(
            texts(&last2),
            vec!["c", "d"],
            "the 2 last, in insertion order"
        );
    }

    #[tokio::test]
    async fn conversations_are_disjoint_threads() {
        let log = InMemoryConversationLog::default();
        let c1 = conv_id(1);
        let c2 = conv_id(2);
        log.append(c1, turn(c1, turn_id(1), TurnRole::Prompt, "c1-a"))
            .await
            .unwrap();
        log.append(c2, turn(c2, turn_id(2), TurnRole::Prompt, "c2-a"))
            .await
            .unwrap();
        log.append(c1, turn(c1, turn_id(3), TurnRole::Response, "c1-b"))
            .await
            .unwrap();

        assert_eq!(
            texts(&log.read(c1, None).await.unwrap()),
            vec!["c1-a", "c1-b"]
        );
        assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
        // A cursor from one thread never leaks tours from another.
        assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
    }
}
