//! L2 integration tests for [`FsConversationLog`] against a **real** temp directory
//! (cadrage « persistance conversationnelle », lot P2, critères §19.6).
//!
//! These lock the *durable* behaviour the in-memory double of P1 cannot prove:
//! - one JSONL line per appended turn, each line valid JSON;
//! - persistence survives a "restart" (a fresh instance on the same root relits all);
//! - two conversations land in two disjoint `log.jsonl` files (no leak);
//! - a corrupted/truncated line is silently skipped (no panic, no hard error);
//! - missing file/conversation => empty (never an error);
//! - the cursor/`last` contract (exclusive `since`, `last(0)`, `n>len`, `n<len`);
//! - two concurrent appends on the same conversation => 2 lines, no corruption.
//!
//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur
//! `project_store.rs`) — it yields an **absolute** path, as `ProjectPath::new`
//! requires, and is cleaned up on drop. No extra dependency is pulled in.

use std::path::PathBuf;

use domain::conversation::ConversationId;
use domain::conversation_log::{
    bound_handoff_summary, ConversationArchive, ConversationLog, ConversationTurn, Handoff,
    HandoffStore, HandoffSummarizer, PageCursor, PageDirection, ProviderSessionStore, TurnId,
    TurnRole, HANDOFF_SUMMARY_MAX_CHARS, MAX_ARCHIVE_SEGMENTS, ROTATE_AFTER_BYTES,
};
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use infrastructure::{
    FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
    TURN_LINE_MAX_CHARS, WINDOW,
};
use uuid::Uuid;

// ---------------------------------------------------------------------------
// Test scaffolding
// ---------------------------------------------------------------------------

/// A unique scratch directory under the OS temp dir, cleaned up on drop. Its path
/// is absolute, so `ProjectPath::new` accepts it directly as a project root.
struct TempDir(PathBuf);
impl TempDir {
    fn new() -> Self {
        let p = std::env::temp_dir().join(format!("idea-l2-convlog-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&p).unwrap();
        Self(p)
    }
    /// The project root as a [`ProjectPath`] (absolute, as the composition root passes).
    fn project_path(&self) -> ProjectPath {
        ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
    }
    /// `<root>/.ideai/conversations/<conversationId>/log.jsonl` — the raw log file.
    fn log_path(&self, conversation: ConversationId) -> PathBuf {
        self.conversation_dir(conversation).join("log.jsonl")
    }
    /// `<root>/.ideai/conversations/<conversationId>/` — a conversation's dir.
    fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
        self.0
            .join(".ideai")
            .join("conversations")
            .join(conversation.to_string())
    }
    /// `<root>/.ideai/conversations/<conversationId>/handoff.md` — the handoff file (P3).
    fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
        self.conversation_dir(conversation).join("handoff.md")
    }
    /// `<root>/.ideai/conversations/<conversationId>/handoff.md.tmp` — the atomic-write tmp.
    fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
        self.conversation_dir(conversation).join("handoff.md.tmp")
    }
    /// `<root>/.ideai/conversations/<conversationId>/providers.json` — the per-provider sessions (P5).
    fn providers_path(&self, conversation: ConversationId) -> PathBuf {
        self.conversation_dir(conversation).join("providers.json")
    }
    /// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
    fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
        self.conversation_dir(conversation)
            .join("providers.json.tmp")
    }
}
impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

// Deterministic constructors (calqués sur les tests P1 de conversation_log.rs).
fn conv_id(n: u128) -> ConversationId {
    ConversationId::from_uuid(Uuid::from_u128(n))
}
fn turn_id(n: u128) -> TurnId {
    TurnId::from_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()
}

// ---------------------------------------------------------------------------
// append persistence — one JSONL line per turn, each line valid JSON
// ---------------------------------------------------------------------------

#[tokio::test]
async fn append_writes_one_valid_json_line_per_turn() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    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();

    // Inspect the raw file: nb of non-empty lines == nb of appends, each valid JSON.
    let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
    let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
    assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
    for line in &lines {
        let parsed: ConversationTurn = serde_json::from_str(line)
            .unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
        // camelCase shape leaks through to the file (sanity on the persisted format).
        assert!(
            line.contains("\"atMs\""),
            "expected camelCase atMs in {line:?}"
        );
        let _ = parsed;
    }
}

// ---------------------------------------------------------------------------
// survives a "restart" — a fresh instance on the same root relits everything
// ---------------------------------------------------------------------------

#[tokio::test]
async fn persists_across_a_fresh_instance_restart() {
    let tmp = TempDir::new();
    let c = conv_id(7);

    // First instance appends three turns, then is dropped.
    {
        let log = FsConversationLog::new(&tmp.project_path());
        for (id, role, txt) in [
            (1, TurnRole::Prompt, "a"),
            (2, TurnRole::Response, "b"),
            (3, TurnRole::Prompt, "c"),
        ] {
            log.append(c, turn(c, turn_id(id), role, txt))
                .await
                .unwrap();
        }
    }

    // A brand-new instance on the same root reads it all back (no in-memory cache).
    let reborn = FsConversationLog::new(&tmp.project_path());
    let all = reborn.read(c, None).await.unwrap();
    assert_eq!(texts(&all), vec!["a", "b", "c"]);
    assert_eq!(texts(&reborn.last(c, 2).await.unwrap()), vec!["b", "c"]);
}

// ---------------------------------------------------------------------------
// disjoint conversations => disjoint files, no leak
// ---------------------------------------------------------------------------

#[tokio::test]
async fn conversations_land_in_disjoint_files() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    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();

    // Two distinct files exist on disk.
    let p1 = tmp.log_path(c1);
    let p2 = tmp.log_path(c2);
    assert!(p1.exists(), "c1 log file must exist");
    assert!(p2.exists(), "c2 log file must exist");
    assert_ne!(p1, p2, "the two conversations must use distinct files");

    // No cross-leak through the API, and the c2 file holds only c2's single line.
    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"]);
    let c2_raw = std::fs::read_to_string(&p2).unwrap();
    assert_eq!(
        c2_raw.lines().filter(|l| !l.trim().is_empty()).count(),
        1,
        "c2 file must not contain c1's turns"
    );
    assert!(
        !c2_raw.contains("c1-"),
        "no c1 content leaked into c2's file"
    );
}

// ---------------------------------------------------------------------------
// robustness — a corrupted/truncated line is silently skipped
// ---------------------------------------------------------------------------

#[tokio::test]
async fn corrupted_line_is_skipped_without_panic() {
    let tmp = TempDir::new();
    let c = conv_id(5);

    // Append two real turns first (creates the dir + file).
    {
        let log = FsConversationLog::new(&tmp.project_path());
        log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "good-1"))
            .await
            .unwrap();
        log.append(c, turn(c, turn_id(2), TurnRole::Response, "good-2"))
            .await
            .unwrap();
    }

    // Hand-write a garbage (truncated) line directly into the JSONL, as a crash mid-write would.
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(tmp.log_path(c))
            .unwrap();
        writeln!(f, "{{tronqué").unwrap();
    }

    // A fresh instance must skip the junk line and return only the two valid turns.
    let log = FsConversationLog::new(&tmp.project_path());
    let all = log.read(c, None).await.unwrap();
    assert_eq!(
        texts(&all),
        vec!["good-1", "good-2"],
        "garbage line skipped"
    );
    // `last` must be just as tolerant.
    assert_eq!(
        texts(&log.last(c, 5).await.unwrap()),
        vec!["good-1", "good-2"]
    );
    // And the cursor still works across the corrupted tail.
    assert_eq!(
        texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
        vec!["good-2"]
    );
}

#[tokio::test]
async fn good_line_appended_after_corruption_is_still_read() {
    let tmp = TempDir::new();
    let c = conv_id(6);
    let log = FsConversationLog::new(&tmp.project_path());

    log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "before"))
        .await
        .unwrap();

    // Inject a junk line between two good appends.
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(tmp.log_path(c))
            .unwrap();
        writeln!(f, "not json at all }}}}").unwrap();
    }

    log.append(c, turn(c, turn_id(2), TurnRole::Response, "after"))
        .await
        .unwrap();

    let all = log.read(c, None).await.unwrap();
    assert_eq!(
        texts(&all),
        vec!["before", "after"],
        "junk in the middle is skipped, both reals survive"
    );
}

// ---------------------------------------------------------------------------
// missing file / conversation => empty (never an error)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn missing_conversation_reads_empty() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());

    // Nothing was ever appended: no .ideai dir at all.
    assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
    assert!(log
        .read(conv_id(99), Some(turn_id(1)))
        .await
        .unwrap()
        .is_empty());
    assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
}

// ---------------------------------------------------------------------------
// cursor / last contract (mirrors the P1 port contract, now over the Fs adapter)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn read_cursor_is_strictly_exclusive() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    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();
    }

    assert_eq!(
        texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
        vec!["b", "c"]
    );
    assert_eq!(
        texts(&log.read(c, Some(turn_id(2))).await.unwrap()),
        vec!["c"]
    );
    // Cursor on the last id => nothing after.
    assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
    // Unknown cursor => empty (cohérent avec le double in-memory de P1).
    assert!(log.read(c, Some(turn_id(999))).await.unwrap().is_empty());
}

#[tokio::test]
async fn last_contract_zero_and_bounds() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    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();
    }

    // last(0) => empty.
    assert!(log.last(c, 0).await.unwrap().is_empty());
    // last(n < len) => the n last, in insertion order.
    assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
    // last(n > len) => everything.
    assert_eq!(
        texts(&log.last(c, 10).await.unwrap()),
        vec!["a", "b", "c", "d"]
    );
    // last(n == len) => everything.
    assert_eq!(
        texts(&log.last(c, 4).await.unwrap()),
        vec!["a", "b", "c", "d"]
    );
}

// ---------------------------------------------------------------------------
// concurrency — two concurrent appends => 2 intact lines, no corruption
// ---------------------------------------------------------------------------

#[tokio::test]
async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
    use std::sync::Arc;

    let tmp = TempDir::new();
    let log = Arc::new(FsConversationLog::new(&tmp.project_path()));
    let c = conv_id(42);

    let l1 = Arc::clone(&log);
    let l2 = Arc::clone(&log);
    let h1 = tokio::spawn(async move {
        l1.append(c, turn(c, turn_id(1), TurnRole::Prompt, "first"))
            .await
            .unwrap();
    });
    let h2 = tokio::spawn(async move {
        l2.append(c, turn(c, turn_id(2), TurnRole::Response, "second"))
            .await
            .unwrap();
    });
    h1.await.unwrap();
    h2.await.unwrap();

    // Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
    let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
    let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
    assert_eq!(
        lines.len(),
        2,
        "two concurrent appends => two lines, got: {raw:?}"
    );
    for line in &lines {
        serde_json::from_str::<ConversationTurn>(line)
            .unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
    }
    // Both turns are present (order between the two is unspecified under a race).
    let mut got = texts(&log.read(c, None).await.unwrap());
    got.sort();
    assert_eq!(got, vec!["first".to_string(), "second".to_string()]);
}

// ===========================================================================
// P3 — FsHandoffStore (handoff.md): le point de reprise, un par conversation.
//
// Choix d'emplacement : ces cas vivent dans CE fichier (et non un frère) car ils
// réutilisent à l'identique le scaffolding L2 de P2 — `TempDir` maison (chemin
// absolu pour `ProjectPath::new`), constructeurs déterministes `conv_id`/`turn_id`,
// et la même convention de dossier `<root>/.ideai/conversations/<id>/`. Tout regrouper
// garde la cartographie « persistance conversationnelle » au même endroit.
// ===========================================================================

/// Écrit un `handoff.md` brut (corruption volontaire), créant le dossier au besoin.
fn write_raw_handoff(tmp: &TempDir, c: ConversationId, content: &str) {
    std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
    std::fs::write(tmp.handoff_path(c), content).unwrap();
}

// ---------------------------------------------------------------------------
// round-trip exact — summary_md multi-ligne + objective: Some(...)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_round_trip_multiline_summary_with_objective() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let c = conv_id(1);

    // summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
    // pour éprouver la fidélité octet-pour-octet du corps.
    let summary =
        "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing  \n";
    let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));

    store.save(c, handoff.clone()).await.unwrap();
    let loaded = store.load(c).await.unwrap();

    assert_eq!(
        loaded,
        Some(handoff.clone()),
        "round-trip doit redonner le Handoff exact"
    );
    let loaded = loaded.unwrap();
    assert_eq!(
        loaded.summary_md, summary,
        "summary_md conservé octet pour octet"
    );
    assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
    assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
}

// ---------------------------------------------------------------------------
// round-trip exact — objective: None
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_round_trip_without_objective() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let c = conv_id(2);

    let summary = "résumé simple\nsur deux lignes\n";
    let handoff = Handoff::new(summary, turn_id(7), None);

    store.save(c, handoff.clone()).await.unwrap();
    let loaded = store.load(c).await.unwrap();

    assert_eq!(loaded, Some(handoff), "round-trip exact sans objective");
    let loaded = store.load(c).await.unwrap().unwrap();
    assert_eq!(loaded.objective, None, "objective reste None");
    assert_eq!(loaded.summary_md, summary);
    assert_eq!(loaded.up_to, turn_id(7));

    // Sanity sur le format brut : pas de ligne `objective:` quand None.
    let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
    assert!(
        !raw.contains("objective:"),
        "aucune clé objective ne doit être écrite, got: {raw:?}"
    );
    assert!(
        raw.contains("upTo: "),
        "upTo toujours présent, got: {raw:?}"
    );
}

// ---------------------------------------------------------------------------
// absent ⇒ Ok(None)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_absent_loads_none() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());

    // Aucune conversation jamais écrite : pas de dossier .ideai du tout.
    assert_eq!(store.load(conv_id(123)).await.unwrap(), None);
}

// ---------------------------------------------------------------------------
// écriture atomique — pas de .tmp résiduel, fichier final complet
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_save_is_atomic_no_tmp_left_behind() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let c = conv_id(3);

    let handoff = Handoff::new("corps complet\n", turn_id(9), Some("but".to_string()));
    store.save(c, handoff.clone()).await.unwrap();

    // Aucun handoff.md.tmp résiduel après save.
    assert!(
        !tmp.handoff_tmp_path(c).exists(),
        "le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
    );
    // Le fichier final existe et est complet/relisible.
    assert!(
        tmp.handoff_path(c).exists(),
        "handoff.md final doit exister"
    );
    assert_eq!(
        store.load(c).await.unwrap(),
        Some(handoff),
        "contenu final lisible et complet"
    );
}

// ---------------------------------------------------------------------------
// overwrite — deux save successifs => load = le dernier (pas d'accumulation)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_overwrite_keeps_only_the_last() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let c = conv_id(4);

    let first = Handoff::new("première version\n", turn_id(1), Some("obj-1".to_string()));
    let second = Handoff::new("deuxième version\nplus longue\n", turn_id(2), None);

    store.save(c, first).await.unwrap();
    store.save(c, second.clone()).await.unwrap();

    // load renvoie le dernier, aucune trace du premier.
    assert_eq!(
        store.load(c).await.unwrap(),
        Some(second),
        "load doit renvoyer le dernier save"
    );
    let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
    assert!(
        !raw.contains("première version"),
        "le contenu du premier save ne doit pas subsister"
    );
    assert!(
        !raw.contains("obj-1"),
        "l'objective du premier save ne doit pas subsister"
    );
}

// ---------------------------------------------------------------------------
// isolation par conversationId — deux convs => deux handoff distincts
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_is_isolated_per_conversation() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let c1 = conv_id(10);
    let c2 = conv_id(20);

    let h1 = Handoff::new("résumé c1\n", turn_id(11), Some("obj-c1".to_string()));
    let h2 = Handoff::new("résumé c2\n", turn_id(22), None);

    store.save(c1, h1.clone()).await.unwrap();
    store.save(c2, h2.clone()).await.unwrap();

    // Chacune relit le sien, sans fuite.
    assert_eq!(store.load(c1).await.unwrap(), Some(h1));
    assert_eq!(store.load(c2).await.unwrap(), Some(h2));

    // Deux fichiers distincts sur disque.
    let p1 = tmp.handoff_path(c1);
    let p2 = tmp.handoff_path(c2);
    assert_ne!(
        p1, p2,
        "deux conversations => deux fichiers handoff distincts"
    );
    assert!(p1.exists() && p2.exists());
    let raw2 = std::fs::read_to_string(&p2).unwrap();
    assert!(
        !raw2.contains("résumé c1"),
        "aucune fuite de c1 dans le handoff de c2"
    );
    assert!(
        !raw2.contains("obj-c1"),
        "aucune fuite de l'objective de c1 dans c2"
    );
}

// ---------------------------------------------------------------------------
// corruption ⇒ Err(Serialization), aucun panic — les 5 cas fournis
// ---------------------------------------------------------------------------

#[tokio::test]
async fn handoff_corruption_yields_serialization_error_no_panic() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());

    // (1) pas de `---` ouvrant.
    // (2) `---` fermant absent.
    // (3) clé `upTo` absente.
    // (4) `upTo` non-UUID.
    // (5) ligne de front-matter sans `:`.
    let cases: [(u128, &str, &str); 5] = [
        (
            101,
            "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n",
            "fence ouvrant absent",
        ),
        (
            102,
            "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n",
            "fence fermant absent",
        ),
        (103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
        (104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
        (
            105,
            "---\nligne sans deux-points\n---\ncorps\n",
            "ligne front-matter sans `:`",
        ),
    ];

    for (n, content, label) in cases {
        let c = conv_id(n);
        write_raw_handoff(&tmp, c, content);

        let result = store.load(c).await;
        match result {
            Err(StoreError::Serialization(msg)) => {
                assert!(
                    msg.starts_with("handoff.md:"),
                    "cas «{label}» : message préfixé `handoff.md:` attendu, got: {msg:?}"
                );
            }
            other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
        }
    }
}

// ===========================================================================
// P4 — HeuristicHandoffSummarizer (port HandoffSummarizer, §19.6)
//
// Tests du repli incrémental, déterministe, sans I/O. Réutilise les helpers
// `conv_id` / `turn_id` / `turn` ci-dessus. Placés ici (cible infra) car l'impl
// `HeuristicHandoffSummarizer` est infra-side ; même module de test que P2/P3.
//
// Format reparsable verrouillé (cf. `summarizer.rs`) :
//   - ligne d'objectif : `**Objectif :** <obj>`
//   - une ligne par tour : `- **<Prompt|Response|Tool>:** <texte aplati>`
// ===========================================================================

/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
fn turn_lines(summary_md: &str) -> Vec<&str> {
    summary_md
        .lines()
        .filter(|l| l.starts_with("- **"))
        .collect()
}

/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
/// objectif extrait du 1er Prompt.
#[tokio::test]
async fn fold_none_renders_base_window_objective_and_cursor() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let turns = vec![
        turn(c, turn_id(1), TurnRole::Prompt, "Implémente la feature X"),
        turn(c, turn_id(2), TurnRole::ToolActivity, "ran grep"),
        turn(c, turn_id(3), TurnRole::Response, "fait"),
    ];

    let h = s.fold(None, &turns).await;

    // Objectif extrait du 1er Prompt.
    assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
    assert!(
        h.summary_md
            .contains("**Objectif :** Implémente la feature X"),
        "ligne d'objectif attendue, got:\n{}",
        h.summary_md
    );
    // Curseur = dernier id de l'incrément.
    assert_eq!(h.up_to, turn_id(3));
    // Les 3 tours rendus, un par ligne, avec le bon label.
    let lines = turn_lines(&h.summary_md);
    assert_eq!(
        lines.len(),
        3,
        "3 lignes-tours attendues, got:\n{}",
        h.summary_md
    );
    assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
    assert_eq!(lines[1], "- **Tool:** ran grep");
    assert_eq!(lines[2], "- **Response:** fait");
}

/// Incrément seulement : `h2 = fold(Some(h1), [t6])` étend h1 (t1..t5) sans
/// re-fournir t1..t5 ; curseur avance à t6.
#[tokio::test]
async fn fold_is_incremental_does_not_re_pass_old_turns() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let first = vec![
        turn(c, turn_id(1), TurnRole::Prompt, "obj un"),
        turn(c, turn_id(2), TurnRole::Response, "r2"),
        turn(c, turn_id(3), TurnRole::Response, "r3"),
        turn(c, turn_id(4), TurnRole::Response, "r4"),
        turn(c, turn_id(5), TurnRole::Response, "r5"),
    ];
    let h1 = s.fold(None, &first).await;

    // Le 2e appel ne re-fournit QUE t6.
    let h2 = s
        .fold(
            Some(h1.clone()),
            &[turn(c, turn_id(6), TurnRole::Response, "r6")],
        )
        .await;

    let lines = turn_lines(&h2.summary_md);
    assert_eq!(
        lines.len(),
        6,
        "t1..t6 reconstitués depuis prev + incrément"
    );
    assert_eq!(lines[0], "- **Prompt:** obj un");
    assert_eq!(lines[5], "- **Response:** r6");
    assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
    // Aucune duplication de t5 (la borne de prev) — exactement une occurrence.
    assert_eq!(h2.summary_md.matches("- **Response:** r5").count(), 1);
}

/// Borne WINDOW : folder > WINDOW tours ⇒ exactement WINDOW lignes, et ce sont
/// les DERNIERS (les plus anciens évincés).
#[tokio::test]
async fn fold_truncates_to_window_keeping_the_last() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let total = WINDOW + 5; // 25 si WINDOW=20.
    let mut turns = Vec::new();
    // 1er tour = Prompt (pour avoir un objectif) ; le reste = Response numérotés.
    turns.push(turn(c, turn_id(1), TurnRole::Prompt, "objectif global"));
    for n in 2..=(total as u128) {
        turns.push(turn(c, turn_id(n), TurnRole::Response, &format!("r{n}")));
    }

    let h = s.fold(None, &turns).await;

    let lines = turn_lines(&h.summary_md);
    assert_eq!(lines.len(), WINDOW, "exactement WINDOW lignes-tours");
    // Les plus anciens (Prompt + premiers Response) sont évincés.
    assert!(
        !h.summary_md.contains("- **Prompt:** objectif global"),
        "le 1er tour doit être évincé de la fenêtre"
    );
    assert!(!h.summary_md.contains("- **Response:** r2 "), "r2 évincé");
    // Le tout dernier reste, en dernière ligne.
    let last_n = total as u128;
    assert_eq!(lines[WINDOW - 1], format!("- **Response:** r{last_n}"));
    // La 1re ligne conservée = total - WINDOW + 1 (numérotation des tours).
    let first_kept = last_n - WINDOW as u128 + 1;
    assert_eq!(lines[0], format!("- **Response:** r{first_kept}"));
    // L'objectif extrait du 1er Prompt survit même si ce Prompt sort de la fenêtre.
    assert_eq!(h.objective.as_deref(), Some("objectif global"));
    assert_eq!(h.up_to, turn_id(last_n));
}

/// Objectif figé : un `prev` avec objectif le conserve même si l'incrément a un
/// autre 1er Prompt.
#[tokio::test]
async fn fold_keeps_existing_objective_over_new_prompt() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let prev = Handoff::new(
        "**Objectif :** but initial".to_string(),
        turn_id(1),
        Some("but initial".to_string()),
    );

    let h = s
        .fold(
            Some(prev),
            &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")],
        )
        .await;

    assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
    assert!(
        h.summary_md.contains("**Objectif :** but initial")
            && !h.summary_md.contains("**Objectif :** un autre but"),
        "l'objectif ne doit pas être réécrit, got:\n{}",
        h.summary_md
    );
}

/// `fold(None, [])` ⇒ handoff vide cohérent : summary vide, curseur nil, objectif None.
#[tokio::test]
async fn fold_none_empty_yields_empty_handoff() {
    let s = HeuristicHandoffSummarizer::new();
    let h = s.fold(None, &[]).await;
    assert_eq!(h.summary_md, "");
    assert_eq!(h.up_to, TurnId::from_uuid(Uuid::nil()));
    assert_eq!(h.objective, None);
}

/// `fold(Some(h), [])` ⇒ `h` strictement inchangé.
#[tokio::test]
async fn fold_some_empty_returns_prev_unchanged() {
    let s = HeuristicHandoffSummarizer::new();
    let prev = Handoff::new(
        "**Objectif :** but\n\n- **Prompt:** but".to_string(),
        turn_id(7),
        Some("but".to_string()),
    );
    let h = s.fold(Some(prev.clone()), &[]).await;
    assert_eq!(h, prev, "rien de neuf ⇒ prev rendu tel quel");
}

/// Déterminisme : deux `fold` identiques ⇒ sorties strictement égales.
#[tokio::test]
async fn fold_is_deterministic() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let turns = vec![
        turn(c, turn_id(1), TurnRole::Prompt, "tâche"),
        turn(c, turn_id(2), TurnRole::Response, "ok"),
    ];
    let a = s.fold(None, &turns).await;
    let b = s.fold(None, &turns).await;
    assert_eq!(a, b);
}

/// Robustesse format : un tour dont le texte contient des sauts de ligne et la
/// séquence `- **` reste UNE seule ligne (collapse) et ne casse pas le reparse de
/// la fenêtre au repli suivant.
#[tokio::test]
async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    // Texte piégeux : retours à la ligne + une séquence ressemblant à un marqueur.
    let nasty = "ligne une\n- **Prompt:** faux marqueur\nligne   trois";
    let turns = vec![
        turn(c, turn_id(1), TurnRole::Prompt, "vrai objectif"),
        turn(c, turn_id(2), TurnRole::Response, nasty),
    ];
    let h1 = s.fold(None, &turns).await;

    // Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
    let lines = turn_lines(&h1.summary_md);
    assert_eq!(
        lines.len(),
        2,
        "2 lignes-tours seulement, pas de ligne fantôme"
    );
    assert_eq!(
        lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
        "texte multi-lignes + marqueur aplati en une ligne"
    );

    // Sonde de cohérence de fenêtre : un repli incrémental reparse proprement.
    // NOTE de fragilité (rapportée à Main) : la ligne aplatie contient toujours la
    // sous-chaîne `- **Prompt:**`, MAIS comme elle ne COMMENCE pas par `- **`
    // (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
    // la compte comme UNE seule ligne — la fenêtre reste cohérente.
    let h2 = s
        .fold(
            Some(h1.clone()),
            &[turn(c, turn_id(3), TurnRole::Response, "suite")],
        )
        .await;
    let lines2 = turn_lines(&h2.summary_md);
    assert_eq!(
        lines2.len(),
        3,
        "fenêtre cohérente après repli (pas de split parasite)"
    );
    assert_eq!(lines2[2], "- **Response:** suite");
    assert_eq!(h2.up_to, turn_id(3));
}

// ---------------------------------------------------------------------------
// LS5 — borne par-ligne (`render_turn`/`elide`) + boundedness sur 100 replis +
// round-trip Fs réel avec `summary_md` borné. Le préfixe de ligne de tour, mesuré
// une fois pour les assertions de borne structurelle.
// ---------------------------------------------------------------------------

/// Le préfixe `- **Response:** ` (en caractères), partagé par les bornes structurelles.
const RESPONSE_PREFIX: &str = "- **Response:** ";

/// LS5 — un tour `Response` énorme : sa ligne rendue est **élidée** (le texte aplati est
/// borné à [`TURN_LINE_MAX_CHARS`] + le marqueur `…`), le préfixe `- **role:**` reste
/// intact, et la ligne reste reparsable. Testé via `fold` (`render_turn` est privé).
#[tokio::test]
async fn fold_elides_a_huge_response_line_keeping_prefix() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);
    let huge = "z".repeat(10_000);
    let h = s
        .fold(None, &[turn(c, turn_id(1), TurnRole::Response, &huge)])
        .await;

    let line = turn_lines(&h.summary_md)[0];
    // Préfixe intact ⇒ ligne reparsable.
    assert!(
        line.starts_with(RESPONSE_PREFIX),
        "préfixe de tour intact : {line}"
    );
    // Élidée : marqueur `…` final, et la ligne n'a pas conservé les 10 000 caractères.
    assert!(line.ends_with('…'), "marqueur d'élision présent : {line}");
    // Borne structurelle : préfixe + TURN_LINE_MAX_CHARS (texte borné) + 1 pour `…`.
    let max_line = RESPONSE_PREFIX.chars().count() + TURN_LINE_MAX_CHARS + 1;
    assert!(
        line.chars().count() <= max_line,
        "ligne bornée à {max_line} car., obtenu {} : {line}",
        line.chars().count()
    );
    // Le texte aplati lui-même (hors préfixe, hors `…`) est borné à TURN_LINE_MAX_CHARS.
    let text_chars = line.chars().count() - RESPONSE_PREFIX.chars().count() - 1;
    assert_eq!(
        text_chars, TURN_LINE_MAX_CHARS,
        "texte aplati borné à TURN_LINE_MAX_CHARS"
    );
}

/// LS5 — 100 replis incrémentaux : la `WINDOW` borne le nombre de lignes (la croissance
/// ne s'accumule pas), l'objectif extrait du 1er prompt est **figé**, la fenêtre reparse
/// proprement, et — pour des tours de taille réaliste — le `summary_md` reste sous le
/// plafond universel [`HANDOFF_SUMMARY_MAX_CHARS`]. (Le plafond *dur*, valable même pour
/// un résumeur pathologique/LLM, est garanti séparément par `bound_handoff_summary` —
/// cf. les tests domaine/application.)
#[tokio::test]
async fn fold_a_hundred_increments_stays_bounded_and_window_reparses() {
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);

    // 1er tour = Prompt énonçant l'objectif ; replis incrémentaux ensuite.
    let mut handoff = s
        .fold(
            None,
            &[turn(
                c,
                turn_id(1),
                TurnRole::Prompt,
                "objectif initial figé",
            )],
        )
        .await;
    for n in 2..=100u128 {
        let text = format!("tour numero {n} {}", "détail ".repeat(15)); // ~120 car.
        handoff = s
            .fold(
                Some(handoff),
                &[turn(c, turn_id(n), TurnRole::Response, &text)],
            )
            .await;
    }

    // WINDOW respectée : jamais plus de WINDOW lignes-tours, malgré 100 replis.
    let lines = turn_lines(&handoff.summary_md);
    assert_eq!(lines.len(), WINDOW, "WINDOW borne le nombre de lignes");
    // Reparse stable : chaque ligne conservée est une ligne de tour bien formée.
    for line in &lines {
        assert!(line.starts_with("- **"), "ligne reparsable : {line}");
    }
    // Objectif figé depuis le 1er prompt.
    assert_eq!(handoff.objective.as_deref(), Some("objectif initial figé"));
    assert!(handoff
        .summary_md
        .contains("**Objectif :** objectif initial figé"));
    // Curseur = dernier tour intégré.
    assert_eq!(handoff.up_to, turn_id(100));
    // Borné sous le plafond universel (tours réalistes ⇒ la WINDOW suffit).
    assert!(
        handoff.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
        "summary borné à HANDOFF_SUMMARY_MAX_CHARS, obtenu {}",
        handoff.summary_md.chars().count()
    );
}

/// LS5 — round-trip `FsHandoffStore` **réel** : après un fold multi-tours (texte volumineux
/// ⇒ summary potentiellement > plafond) PUIS `bound_handoff_summary`, le `handoff.md` écrit
/// sur disque relit un `summary_md` ≤ [`HANDOFF_SUMMARY_MAX_CHARS`], objectif/curseur
/// préservés, et reste reparsable.
#[tokio::test]
async fn fs_handoff_round_trip_persists_bounded_summary() {
    let tmp = TempDir::new();
    let store = FsHandoffStore::new(&tmp.project_path());
    let s = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);

    // Fold de tours VOLUMINEUX : chaque ligne est élidée à ~257 car., 20 lignes ⇒ le
    // summary heuristique dépasse le plafond universel (≈ 5 000 > 4096).
    let mut handoff = s
        .fold(None, &[turn(c, turn_id(1), TurnRole::Prompt, "objectif")])
        .await;
    for n in 2..=60u128 {
        let big = "m".repeat(3_000);
        handoff = s
            .fold(
                Some(handoff),
                &[turn(c, turn_id(n), TurnRole::Response, &big)],
            )
            .await;
    }
    assert!(
        handoff.summary_md.chars().count() > HANDOFF_SUMMARY_MAX_CHARS,
        "pré-condition : le fold heuristique seul dépasse le plafond ({})",
        handoff.summary_md.chars().count()
    );

    // Pipeline réel : borne universelle AVANT persistance.
    let bounded = bound_handoff_summary(handoff.clone(), HANDOFF_SUMMARY_MAX_CHARS);
    store.save(c, bounded.clone()).await.expect("save ok");

    let loaded = store.load(c).await.expect("load ok").expect("present");
    assert!(
        loaded.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
        "handoff.md sur disque borné, obtenu {}",
        loaded.summary_md.chars().count()
    );
    assert_eq!(loaded.objective, bounded.objective, "objectif préservé");
    assert_eq!(loaded.up_to, bounded.up_to, "curseur préservé");
    // Reparsable : au moins une ligne de tour, toutes bien préfixées.
    let kept = turn_lines(&loaded.summary_md);
    assert!(!kept.is_empty(), "au moins une ligne de tour conservée");
    for line in kept {
        assert!(line.starts_with("- **"), "ligne reparsable : {line}");
    }
}

// ===========================================================================
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
// (chemin absolu pour `ProjectPath::new`), constructeurs déterministes
// `conv_id`/`turn_id`, convention de dossier `<root>/.ideai/conversations/<id>/`.
//
// Format verrouillé : objet JSON plat `{ "<providerId>": "<resumableId>", ... }`.
// Contrat : get/set par provider ; coexistence multi-providers sans perte ;
// absent ⇒ Ok(None) ; corrompu ⇒ Err(Serialization) ; read-modify-write atomique
// (tmp+rename, mutex par conversation) ; isolation par ConversationId.
// ===========================================================================

/// Écrit un `providers.json` brut (corruption volontaire), créant le dossier au besoin.
fn write_raw_providers(tmp: &TempDir, c: ConversationId, content: &str) {
    std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
    std::fs::write(tmp.providers_path(c), content).unwrap();
}

// ---------------------------------------------------------------------------
// get/set par provider + round-trip disque via une nouvelle instance
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_set_then_get_round_trips_across_a_fresh_instance() {
    let tmp = TempDir::new();
    let c = conv_id(1);

    // Première instance : set puis get immédiat.
    {
        let store = FsProviderSessionStore::new(&tmp.project_path());
        store.set(c, "claude", "a").await.unwrap();
        assert_eq!(
            store.get(c, "claude").await.unwrap(),
            Some("a".to_string()),
            "get doit rendre la valeur posée"
        );
    }

    // Le fichier existe réellement sur disque, au bon emplacement.
    assert!(
        tmp.providers_path(c).exists(),
        "providers.json doit exister après set"
    );

    // Une nouvelle instance sur le même root relit la persistance (aucun cache mémoire).
    let reborn = FsProviderSessionStore::new(&tmp.project_path());
    assert_eq!(
        reborn.get(c, "claude").await.unwrap(),
        Some("a".to_string()),
        "persistance réelle : relue via une instance neuve"
    );
}

// ---------------------------------------------------------------------------
// coexistence multi-providers : set claude + codex => les deux ; re-set claude
// n'efface pas codex ; sur disque les deux clés sont présentes.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_set_keeps_other_providers_and_disk_holds_both_keys() {
    let tmp = TempDir::new();
    let store = FsProviderSessionStore::new(&tmp.project_path());
    let c = conv_id(2);

    store.set(c, "claude", "a").await.unwrap();
    store.set(c, "codex", "b").await.unwrap();

    // Les deux providers rendent leur valeur.
    assert_eq!(store.get(c, "claude").await.unwrap(), Some("a".to_string()));
    assert_eq!(store.get(c, "codex").await.unwrap(), Some("b".to_string()));

    // Re-set claude (read-modify-write) ne doit PAS effacer codex.
    store.set(c, "claude", "a2").await.unwrap();
    assert_eq!(
        store.get(c, "claude").await.unwrap(),
        Some("a2".to_string()),
        "claude mis à jour"
    );
    assert_eq!(
        store.get(c, "codex").await.unwrap(),
        Some("b".to_string()),
        "codex préservé après re-set de claude"
    );

    // Sur disque, le providers.json contient bien les deux clés (objet JSON plat).
    let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
    let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
    assert_eq!(map.get("claude").map(String::as_str), Some("a2"));
    assert_eq!(map.get("codex").map(String::as_str), Some("b"));
    assert_eq!(map.len(), 2, "exactement les deux providers, got: {raw:?}");
}

// ---------------------------------------------------------------------------
// absent ⇒ Ok(None) (conversation/provider jamais écrit ; dossier inexistant)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_get_absent_yields_none() {
    let tmp = TempDir::new();
    let store = FsProviderSessionStore::new(&tmp.project_path());

    // Conversation jamais écrite : pas de dossier .ideai du tout.
    assert_eq!(
        store.get(conv_id(99), "claude").await.unwrap(),
        None,
        "conversation inexistante ⇒ None (pas d'erreur)"
    );

    // Conversation avec un provider posé, mais provider demandé absent ⇒ None.
    let c = conv_id(98);
    store.set(c, "claude", "a").await.unwrap();
    assert_eq!(
        store.get(c, "codex").await.unwrap(),
        None,
        "clé provider absente ⇒ None"
    );
}

// ---------------------------------------------------------------------------
// corrompu ⇒ Err(Serialization), aucun panic
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_corrupted_file_yields_serialization_error_no_panic() {
    let tmp = TempDir::new();
    let store = FsProviderSessionStore::new(&tmp.project_path());

    // Cas de contenus non-désérialisables en map<String,String>.
    let cases: [(u128, &str, &str); 3] = [
        (
            201,
            "{ ceci n'est pas du json",
            "JSON syntaxiquement invalide",
        ),
        (
            202,
            "[\"pas\", \"un\", \"objet\"]",
            "JSON valide mais pas un objet map",
        ),
        (203, "{\"claude\": 123}", "valeur non-String"),
    ];

    for (n, content, label) in cases {
        let c = conv_id(n);
        write_raw_providers(&tmp, c, content);

        match store.get(c, "claude").await {
            Err(StoreError::Serialization(msg)) => {
                assert!(
                    msg.starts_with("providers.json:"),
                    "cas «{label}» : message préfixé `providers.json:` attendu, got: {msg:?}"
                );
            }
            other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
        }
    }
}

// ---------------------------------------------------------------------------
// atomicité / concurrence : N set concurrents (providers distincts, même conv)
// => la map finale contient les N entrées (aucune perte) ; pas de .tmp résiduel.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_concurrent_sets_keep_all_entries_no_tmp_left() {
    use std::sync::Arc;

    let tmp = TempDir::new();
    let store = Arc::new(FsProviderSessionStore::new(&tmp.project_path()));
    let c = conv_id(42);

    const N: usize = 12;
    let mut handles = Vec::new();
    for i in 0..N {
        let s = Arc::clone(&store);
        handles.push(tokio::spawn(async move {
            let provider = format!("provider-{i}");
            let id = format!("id-{i}");
            s.set(c, &provider, &id).await.unwrap();
        }));
    }
    for h in handles {
        h.await.unwrap();
    }

    // Les N entrées sont toutes présentes : aucun set concurrent n'en a écrasé un autre.
    for i in 0..N {
        assert_eq!(
            store.get(c, &format!("provider-{i}")).await.unwrap(),
            Some(format!("id-{i}")),
            "provider-{i} perdu sous concurrence"
        );
    }

    // Le fichier sur disque contient bien les N clés.
    let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
    let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
    assert_eq!(map.len(), N, "map finale = N entrées, got: {raw:?}");

    // Aucun fichier temporaire résiduel après les renames atomiques.
    assert!(
        !tmp.providers_tmp_path(c).exists(),
        "providers.json.tmp ne doit pas subsister après les set"
    );
}

// ---------------------------------------------------------------------------
// isolation entre deux ConversationId : pas de fuite croisée
// ---------------------------------------------------------------------------

#[tokio::test]
async fn provider_is_isolated_per_conversation() {
    let tmp = TempDir::new();
    let store = FsProviderSessionStore::new(&tmp.project_path());
    let c1 = conv_id(10);
    let c2 = conv_id(20);

    store.set(c1, "claude", "c1-id").await.unwrap();
    store.set(c2, "claude", "c2-id").await.unwrap();

    // Chacune relit la sienne.
    assert_eq!(
        store.get(c1, "claude").await.unwrap(),
        Some("c1-id".to_string())
    );
    assert_eq!(
        store.get(c2, "claude").await.unwrap(),
        Some("c2-id".to_string())
    );

    // Deux fichiers distincts sur disque, sans fuite de contenu.
    let p1 = tmp.providers_path(c1);
    let p2 = tmp.providers_path(c2);
    assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
    assert!(p1.exists() && p2.exists());
    let raw2 = std::fs::read_to_string(&p2).unwrap();
    assert!(
        !raw2.contains("c1-id"),
        "aucune fuite de c1 dans le providers.json de c2"
    );
}

// ===========================================================================
// LS6 — rotation (ConversationArchive) + pagination humaine, I/O réelle.
// Réutilise le scaffolding L2 (TempDir absolu, constructeurs déterministes).
// Convention d'archive : `<conversationId>/log.<k>.jsonl` (k≥1, le plus ancien=1).
// ===========================================================================

/// Liste, triés croissants, les indices `k` des segments d'archive `log.<k>.jsonl`
/// présents sur disque pour `c` (l'actif `log.jsonl` est exclu).
fn archive_segment_indices(tmp: &TempDir, c: ConversationId) -> Vec<usize> {
    let dir = tmp.conversation_dir(c);
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for entry in rd.flatten() {
            if let Some(name) = entry.file_name().to_str() {
                if let Some(mid) = name
                    .strip_prefix("log.")
                    .and_then(|s| s.strip_suffix(".jsonl"))
                {
                    if let Ok(k) = mid.parse::<usize>() {
                        out.push(k);
                    }
                }
            }
        }
    }
    out.sort_unstable();
    out
}

/// Écrit un segment d'archive brut `log.<k>.jsonl` (simulation de crash / dédoublonnage).
fn write_raw_archive_segment(
    tmp: &TempDir,
    c: ConversationId,
    k: usize,
    turns: &[ConversationTurn],
) {
    let dir = tmp.conversation_dir(c);
    std::fs::create_dir_all(&dir).unwrap();
    let mut buf = String::new();
    for t in turns {
        buf.push_str(&serde_json::to_string(t).unwrap());
        buf.push('\n');
    }
    std::fs::write(dir.join(format!("log.{k}.jsonl")), buf).unwrap();
}

/// Tous les `.tmp` restant dans le dossier de la conversation (doivent être absents).
fn leftover_tmp_files(tmp: &TempDir, c: ConversationId) -> Vec<String> {
    let dir = tmp.conversation_dir(c);
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for entry in rd.flatten() {
            if let Some(name) = entry.file_name().to_str() {
                if name.ends_with(".tmp") {
                    out.push(name.to_owned());
                }
            }
        }
    }
    out
}

fn ids(turns: &[ConversationTurn]) -> Vec<TurnId> {
    turns.iter().map(|t| t.id).collect()
}

/// Enregistre `n` tours (id 1..=n) dans l'actif et renvoie le log.
async fn seed_turns(log: &FsConversationLog, c: ConversationId, n: u128) {
    for i in 1..=n {
        let role = if i % 2 == 1 {
            TurnRole::Prompt
        } else {
            TurnRole::Response
        };
        log.append(c, turn(c, turn_id(i), role, &format!("texte du tour {i}")))
            .await
            .unwrap();
    }
}

// --- INV-LS6 : le test pivot -------------------------------------------------

/// INV-LS6 (PIVOT) : après `rotate(keep_from=up_to)`, le curseur `up_to` reste
/// trouvable dans l'ACTIF, donc `ConversationLog::read(since=up_to)` renvoie toujours
/// le bon incrément, et `last(n)` fonctionne. C'est l'invariant qui garantit que la
/// rotation ne casse jamais le fold incrémental du handoff.
#[tokio::test]
async fn inv_ls6_read_since_up_to_survives_rotation() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 10).await;

    // up_to = curseur du handoff (tour 5). On rote en gardant tout depuis 5.
    let up_to = turn_id(5);
    log.rotate(c, up_to).await.expect("rotate ok");

    // Le curseur est dans l'actif ⇒ l'incrément strictement postérieur est intact.
    let inc = log.read(c, Some(up_to)).await.unwrap();
    assert_eq!(
        texts(&inc),
        (6..=10)
            .map(|i| format!("texte du tour {i}"))
            .collect::<Vec<_>>(),
        "read(since=up_to) renvoie l'incrément 6..=10 après rotation"
    );
    // last(n) lit bien la fin (archive-unaware mais l'actif contient la queue).
    let last3 = log.last(c, 3).await.unwrap();
    assert_eq!(ids(&last3), vec![turn_id(8), turn_id(9), turn_id(10)]);
}

// --- archive : déplacement de tête, aucune perte, idempotence ----------------

/// `rotate(keep_from=5)` déplace la tête froide (1..4) dans un segment, garde
/// `[5..10]` dans l'actif, sans perdre aucun tour (∪ actif+archives = tous).
#[tokio::test]
async fn rotate_moves_cold_head_to_a_segment_without_loss() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 10).await;

    log.rotate(c, turn_id(5)).await.expect("rotate ok");

    // Un seul segment d'archive créé (le plus ancien = index 1).
    assert_eq!(archive_segment_indices(&tmp, c), vec![1]);

    // L'actif = [5..10] (keep_from inclus en tête).
    let active = log.read(c, None).await.unwrap();
    assert_eq!(ids(&active), (5..=10).map(turn_id).collect::<Vec<_>>());

    // Aucun tour perdu : la page archive-aware réunit tout, 1..10 en ordre croissant.
    let page = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            200,
        )
        .await
        .unwrap();
    assert_eq!(ids(&page.turns), (1..=10).map(turn_id).collect::<Vec<_>>());
    assert!(!page.has_more, "10 tours ≤ limit ⇒ pas d'autre page");
}

/// `rotate` est idempotente : un 2e appel avec le même `keep_from` (désormais en tête
/// de l'actif) est un no-op — aucun nouveau segment, actif inchangé.
#[tokio::test]
async fn rotate_is_idempotent() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 10).await;

    log.rotate(c, turn_id(5)).await.expect("rotate 1");
    let after1 = log.read(c, None).await.unwrap();
    let segs1 = archive_segment_indices(&tmp, c);

    log.rotate(c, turn_id(5)).await.expect("rotate 2 (no-op)");
    let after2 = log.read(c, None).await.unwrap();
    let segs2 = archive_segment_indices(&tmp, c);

    assert_eq!(ids(&after1), ids(&after2), "actif inchangé au 2e rotate");
    assert_eq!(segs1, segs2, "aucun nouveau segment au 2e rotate");
    assert_eq!(segs2, vec![1], "toujours exactement un segment");
}

/// `keep_from` absent de l'actif (déjà roté / inconnu) ⇒ no-op, jamais de perte.
#[tokio::test]
async fn rotate_with_unknown_keep_from_is_noop() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 5).await;

    log.rotate(c, turn_id(999)).await.expect("rotate noop");
    assert!(archive_segment_indices(&tmp, c).is_empty(), "aucun segment");
    let active = log.read(c, None).await.unwrap();
    assert_eq!(ids(&active), (1..=5).map(turn_id).collect::<Vec<_>>());
}

/// Réécriture de l'actif **atomique** : après rotation, aucun `.tmp` ne traîne et la
/// queue active est intégralement présente (jamais perdue).
#[tokio::test]
async fn rotate_leaves_no_tmp_and_preserves_active_queue() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 8).await;

    log.rotate(c, turn_id(4)).await.expect("rotate ok");

    assert!(
        leftover_tmp_files(&tmp, c).is_empty(),
        "aucun .tmp résiduel après écriture atomique : {:?}",
        leftover_tmp_files(&tmp, c)
    );
    let active = log.read(c, None).await.unwrap();
    assert_eq!(
        ids(&active),
        (4..=8).map(turn_id).collect::<Vec<_>>(),
        "queue active [4..8] intégralement préservée"
    );
}

/// Tête dupliquée (simulation de crash : archive écrite mais actif non swappé) ⇒ la
/// lecture paginée **dédoublonne par TurnId** (chaque tour une seule fois).
#[tokio::test]
async fn page_dedups_duplicated_head_after_crash() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 6).await; // actif = [1..6]

    // Crash simulé : un segment d'archive contenant la tête (1..3) est écrit, mais
    // l'actif n'a PAS été tronqué (il contient toujours 1..6).
    let active_all = log.read(c, None).await.unwrap();
    let head: Vec<ConversationTurn> = active_all[..3].to_vec();
    write_raw_archive_segment(&tmp, c, 1, &head);

    // La page réunit archive(1..3) + actif(1..6) MAIS dédoublonne ⇒ 1..6, pas de doublon.
    let page = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            200,
        )
        .await
        .unwrap();
    assert_eq!(
        ids(&page.turns),
        (1..=6).map(turn_id).collect::<Vec<_>>(),
        "dédoublonnage par TurnId : chaque tour une seule fois"
    );
}

/// `append` ne déclenche **jamais** la rotation : grossir l'actif au-delà du seuil
/// d'octets sans appeler `rotate` ne crée aucun segment d'archive.
#[tokio::test]
async fn append_never_triggers_rotation_even_over_threshold() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);

    // Dépasser le seuil d'octets (1 MiB) : ~16 tours de 80 000 caractères.
    let big = "m".repeat(80_000);
    for i in 1..=16u128 {
        log.append(c, turn(c, turn_id(i), TurnRole::Response, &big))
            .await
            .unwrap();
    }
    let stats = log.stats(c).await.unwrap();
    assert!(
        stats.active_bytes > ROTATE_AFTER_BYTES,
        "pré-condition : actif au-dessus du seuil d'octets ({})",
        stats.active_bytes
    );
    // Pourtant : aucun segment d'archive (la rotation est hors-chemin, jamais sur append).
    assert!(
        archive_segment_indices(&tmp, c).is_empty(),
        "append ne crée jamais d'archive : {:?}",
        archive_segment_indices(&tmp, c)
    );
}

// --- pagination humaine ------------------------------------------------------

/// Page vide pour une conversation vide.
#[tokio::test]
async fn page_of_empty_conversation_is_empty() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    let page = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            50,
        )
        .await
        .unwrap();
    assert!(page.turns.is_empty() && !page.has_more);
}

/// Forward depuis le début, Backward depuis la fin, ancré dans les deux sens ;
/// `has_more` correct ; ordre **chronologique croissant** dans chaque page.
#[tokio::test]
async fn page_forward_backward_anchored_and_has_more() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 10).await;

    // Forward depuis le début, limit 4 ⇒ [1..4], has_more.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            4,
        )
        .await
        .unwrap();
    assert_eq!(ids(&p.turns), (1..=4).map(turn_id).collect::<Vec<_>>());
    assert!(p.has_more);

    // Backward depuis la fin, limit 4 ⇒ [7..10] (ordre croissant !), has_more.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Backward,
            },
            4,
        )
        .await
        .unwrap();
    assert_eq!(ids(&p.turns), (7..=10).map(turn_id).collect::<Vec<_>>());
    assert!(p.has_more);

    // Ancré Forward après le tour 4, limit 3 ⇒ [5,6,7], has_more.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: Some(turn_id(4)),
                direction: PageDirection::Forward,
            },
            3,
        )
        .await
        .unwrap();
    assert_eq!(ids(&p.turns), vec![turn_id(5), turn_id(6), turn_id(7)]);
    assert!(p.has_more);

    // Ancré Backward avant le tour 4, limit 10 ⇒ [1,2,3], plus rien avant.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: Some(turn_id(4)),
                direction: PageDirection::Backward,
            },
            10,
        )
        .await
        .unwrap();
    assert_eq!(ids(&p.turns), vec![turn_id(1), turn_id(2), turn_id(3)]);
    assert!(!p.has_more, "rien avant le tour 1");

    // Dernière page Forward (fin atteinte) ⇒ has_more = false.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: Some(turn_id(7)),
                direction: PageDirection::Forward,
            },
            50,
        )
        .await
        .unwrap();
    assert_eq!(ids(&p.turns), vec![turn_id(8), turn_id(9), turn_id(10)]);
    assert!(!p.has_more);
}

/// La pagination est **archive-aware** : après rotation, une page peut chevaucher
/// plusieurs segments (archive + actif) et reste en ordre croissant continu.
#[tokio::test]
async fn page_spans_multiple_segments_after_rotation() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    seed_turns(&log, c, 12).await;
    log.rotate(c, turn_id(7)).await.expect("rotate"); // archive [1..6], actif [7..12]

    // Une page ancrée Forward autour de la frontière archive/actif renvoie un span continu.
    let p = log
        .page(
            c,
            PageCursor {
                anchor: Some(turn_id(5)),
                direction: PageDirection::Forward,
            },
            4,
        )
        .await
        .unwrap();
    // Tours 6 (archive) puis 7,8,9 (actif) : continuité par-delà la frontière de segment.
    assert_eq!(
        ids(&p.turns),
        vec![turn_id(6), turn_id(7), turn_id(8), turn_id(9)]
    );
    assert!(p.has_more);
}

/// Le **texte complet** d'un tour est préservé (non borné) à la lecture paginée — la
/// borne LS5 ne s'applique qu'au handoff, jamais au log canonique.
#[tokio::test]
async fn page_preserves_full_unbounded_turn_text() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    let big = "Z".repeat(50_000);
    log.append(c, turn(c, turn_id(1), TurnRole::Response, &big))
        .await
        .unwrap();

    let p = log
        .page(
            c,
            PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            50,
        )
        .await
        .unwrap();
    assert_eq!(p.turns.len(), 1);
    assert_eq!(
        p.turns[0].text.chars().count(),
        50_000,
        "texte complet préservé (non borné) à la pagination"
    );
}

// --- backstop MAX_ARCHIVE_SEGMENTS ------------------------------------------

/// Au-delà de [`MAX_ARCHIVE_SEGMENTS`] segments, le(s) plus ANCIEN(s) sont supprimés ;
/// l'actif n'est jamais touché et aucun tour `≥ keep_from` n'est archivé/perdu.
#[tokio::test]
async fn rotation_enforces_max_archive_segments_dropping_oldest() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let c = conv_id(1);
    let total = 40u128;
    seed_turns(&log, c, total).await;

    // Roter `MAX_ARCHIVE_SEGMENTS + 3` fois, en avançant keep_from d'un tour à chaque fois.
    // Chaque rotation déplace une tête d'un tour ⇒ crée un segment.
    let rotations = (MAX_ARCHIVE_SEGMENTS + 3) as u128;
    for k in 2..=(1 + rotations) {
        log.rotate(c, turn_id(k)).await.expect("rotate ok");
    }

    // Backstop : jamais plus de MAX_ARCHIVE_SEGMENTS segments sur disque.
    let segs = archive_segment_indices(&tmp, c);
    assert_eq!(
        segs.len(),
        MAX_ARCHIVE_SEGMENTS,
        "plafonné à MAX_ARCHIVE_SEGMENTS, obtenu {segs:?}"
    );

    // L'actif n'a pas été touché par le backstop : il commence au dernier keep_from.
    let last_keep = 1 + rotations; // = turn_id du dernier keep_from
    let active = log.read(c, None).await.unwrap();
    assert_eq!(
        active.first().map(|t| t.id),
        Some(turn_id(last_keep)),
        "l'actif commence à keep_from (jamais archivé/supprimé)"
    );
    assert_eq!(
        active.last().map(|t| t.id),
        Some(turn_id(total)),
        "la fin de l'actif est intacte"
    );
    // read(since=last_keep) — le curseur survit (INV-LS6) même après backstop.
    let inc = log.read(c, Some(turn_id(last_keep))).await.unwrap();
    assert_eq!(
        inc.first().map(|t| t.id),
        Some(turn_id(last_keep + 1)),
        "l'incrément après le dernier curseur reste correct"
    );
}

// --- cohérence fold-après-rotation (le must-have) ----------------------------

/// COHÉRENCE (must-have) : beaucoup de tours → `rotate(keep_from = up_to)` → encore des
/// tours → un `fold` ultérieur **étend correctement** le handoff. On lit l'actif depuis
/// `up_to` (INV-LS6 : toujours trouvable après rotation), on replie l'incrément, et
/// `up_to` AVANCE. Prouve que la rotation ne casse jamais le fold incrémental réel.
#[tokio::test]
async fn fold_after_rotation_extends_handoff_correctly() {
    let tmp = TempDir::new();
    let log = FsConversationLog::new(&tmp.project_path());
    let summarizer = HeuristicHandoffSummarizer::new();
    let c = conv_id(1);

    // 1. Tours 1..7 enregistrés PUIS repliés ⇒ handoff.up_to = 7.
    seed_turns(&log, c, 7).await;
    let first_batch = log.read(c, None).await.unwrap();
    let handoff = summarizer.fold(None, &first_batch).await;
    let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
    assert_eq!(handoff.up_to, turn_id(7), "curseur = dernier tour replié");

    // 2. Plus de tours arrivent (8..15), non encore repliés, écrits dans l'actif.
    for i in 8..=15u128 {
        let role = if i % 2 == 1 {
            TurnRole::Prompt
        } else {
            TurnRole::Response
        };
        log.append(c, turn(c, turn_id(i), role, &format!("texte du tour {i}")))
            .await
            .unwrap();
    }

    // 3. Rotation au plancher = up_to du handoff (7) : tête froide 1..6 archivée.
    log.rotate(c, handoff.up_to).await.expect("rotate ok");
    // L'actif commence bien au curseur (INV-LS6) ⇒ le curseur reste trouvable.
    assert_eq!(
        ids(&log.read(c, None).await.unwrap()),
        (7..=15).map(turn_id).collect::<Vec<_>>(),
        "actif = [up_to..fin] après rotation"
    );

    // 4. Fold ultérieur : on lit l'incrément depuis up_to (8..15) DANS L'ACTIF, on replie.
    let increment = log.read(c, Some(handoff.up_to)).await.unwrap();
    assert_eq!(
        ids(&increment),
        (8..=15).map(turn_id).collect::<Vec<_>>(),
        "read(since=up_to) renvoie l'incrément 8..15 malgré la rotation"
    );
    let advanced = summarizer.fold(Some(handoff.clone()), &increment).await;
    let advanced = bound_handoff_summary(advanced, HANDOFF_SUMMARY_MAX_CHARS);

    // up_to a AVANCÉ jusqu'au dernier tour, et le résumé intègre les tours récents.
    assert_eq!(
        advanced.up_to,
        turn_id(15),
        "le curseur avance après le fold"
    );
    assert!(
        advanced.summary_md.contains("texte du tour 15"),
        "le fold intègre le tour le plus récent : {}",
        advanced.summary_md
    );
    assert_ne!(
        advanced.up_to, handoff.up_to,
        "la rotation ne fige pas le curseur du handoff"
    );
}
