//! LS6 tests — `RotateConversationLog` (rotation best-effort, plancher = `up_to`) et
//! `ReadConversationPage` (DTO humain riche, texte complet, mapping source), avec des
//! fakes in-memory des ports (aucune I/O réelle ; celle-ci est couverte côté infra).

use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use domain::{
    AgentId, ConversationArchive, ConversationId, ConversationTurn, Handoff, HandoffStore,
    PageCursor, PageDirection, SegmentStats, TurnId, TurnRole, TurnSlice,
};

use application::{
    ConversationArchiveProvider, HandoffProvider, ReadConversationPage, ReadConversationPageInput,
    RotateConversationLog, RotateConversationLogInput, TurnSource,
};

// ---------------------------------------------------------------------------
// Constructeurs déterministes
// ---------------------------------------------------------------------------

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 agent_id(n: u128) -> AgentId {
    AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn root() -> ProjectPath {
    ProjectPath::new("/home/me/proj").unwrap()
}
fn turn(id: TurnId, source: InputSource, role: TurnRole, text: &str) -> ConversationTurn {
    ConversationTurn::new(id, conv_id(1), 1_700_000_000_000, source, role, text)
}

// ---------------------------------------------------------------------------
// Fakes des ports LS6
// ---------------------------------------------------------------------------

/// [`ConversationArchive`] in-memory : `stats` configurables, `page` servie depuis un
/// Vec, et **enregistrement** des appels `rotate` (pour prouver `keep_from = up_to`).
/// Optionnellement, force une erreur store pour prouver la propagation typée.
struct FakeArchive {
    stats: Mutex<SegmentStats>,
    turns: Mutex<Vec<ConversationTurn>>,
    rotate_calls: Mutex<Vec<TurnId>>,
    fail: Mutex<bool>,
}
impl FakeArchive {
    fn build(stats: SegmentStats, turns: Vec<ConversationTurn>, fail: bool) -> Self {
        Self {
            stats: Mutex::new(stats),
            turns: Mutex::new(turns),
            rotate_calls: Mutex::new(Vec::new()),
            fail: Mutex::new(fail),
        }
    }
    fn with_stats(turns: usize, bytes: u64) -> Self {
        Self::build(
            SegmentStats {
                active_turns: turns,
                active_bytes: bytes,
            },
            Vec::new(),
            false,
        )
    }
    fn with_page(turns: Vec<ConversationTurn>) -> Self {
        Self::build(
            SegmentStats {
                active_turns: 0,
                active_bytes: 0,
            },
            turns,
            false,
        )
    }
    fn failing() -> Self {
        Self::build(
            SegmentStats {
                active_turns: 10_000,
                active_bytes: 0,
            },
            Vec::new(),
            true,
        )
    }
    fn rotate_calls(&self) -> Vec<TurnId> {
        self.rotate_calls.lock().unwrap().clone()
    }
}
#[async_trait]
impl ConversationArchive for FakeArchive {
    async fn stats(&self, _c: ConversationId) -> Result<SegmentStats, StoreError> {
        if *self.fail.lock().unwrap() {
            return Err(StoreError::Io("forced".into()));
        }
        Ok(*self.stats.lock().unwrap())
    }
    async fn rotate(&self, _c: ConversationId, keep_from: TurnId) -> Result<(), StoreError> {
        if *self.fail.lock().unwrap() {
            return Err(StoreError::Io("forced".into()));
        }
        self.rotate_calls.lock().unwrap().push(keep_from);
        Ok(())
    }
    async fn page(
        &self,
        _c: ConversationId,
        cursor: PageCursor,
        limit: usize,
    ) -> Result<TurnSlice, StoreError> {
        if *self.fail.lock().unwrap() {
            return Err(StoreError::Io("forced".into()));
        }
        // Pagination minimale Forward-depuis-début suffisante pour les tests DTO.
        let all = self.turns.lock().unwrap().clone();
        let _ = cursor;
        let end = limit.min(all.len());
        Ok(TurnSlice {
            turns: all[..end].to_vec(),
            has_more: end < all.len(),
        })
    }
}

struct FakeArchiveProvider(Arc<dyn ConversationArchive>);
impl ConversationArchiveProvider for FakeArchiveProvider {
    fn conversation_archive_for(
        &self,
        _root: &ProjectPath,
    ) -> Option<Arc<dyn ConversationArchive>> {
        Some(Arc::clone(&self.0))
    }
}

/// Provider qui ne fournit **aucune** archive (best-effort absent).
struct NoArchiveProvider;
impl ConversationArchiveProvider for NoArchiveProvider {
    fn conversation_archive_for(
        &self,
        _root: &ProjectPath,
    ) -> Option<Arc<dyn ConversationArchive>> {
        None
    }
}

#[derive(Default)]
struct FakeHandoffStore(Mutex<Option<Handoff>>);
#[async_trait]
impl HandoffStore for FakeHandoffStore {
    async fn load(&self, _c: ConversationId) -> Result<Option<Handoff>, StoreError> {
        Ok(self.0.lock().unwrap().clone())
    }
    async fn save(&self, _c: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
        *self.0.lock().unwrap() = Some(handoff);
        Ok(())
    }
}
struct FakeHandoffProvider(Arc<dyn HandoffStore>);
impl HandoffProvider for FakeHandoffProvider {
    fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
        Some(Arc::clone(&self.0))
    }
}

fn rotate_uc(
    archive: Arc<dyn ConversationArchive>,
    handoff: Option<Handoff>,
) -> RotateConversationLog {
    let store = Arc::new(FakeHandoffStore(Mutex::new(handoff))) as Arc<dyn HandoffStore>;
    RotateConversationLog::new(
        Arc::new(FakeArchiveProvider(archive)),
        Arc::new(FakeHandoffProvider(store)),
    )
}

fn input() -> RotateConversationLogInput {
    RotateConversationLogInput {
        project_root: root(),
        conversation: conv_id(1),
    }
}

// ---------------------------------------------------------------------------
// RotateConversationLog
// ---------------------------------------------------------------------------

/// Plancher lu du handoff + stats au-dessus du seuil ⇒ `rotate(keep_from = up_to)`.
#[tokio::test]
async fn rotate_uses_handoff_up_to_as_floor_and_calls_rotate() {
    // Stats au-dessus du seuil de tours (défaut 500).
    let archive = Arc::new(FakeArchive::with_stats(10_000, 0));
    let handoff = Handoff::new("résumé", turn_id(42), Some("but".to_owned()));
    let uc = rotate_uc(
        archive.clone() as Arc<dyn ConversationArchive>,
        Some(handoff),
    );

    uc.execute(input()).await.expect("rotate ok");

    assert_eq!(
        archive.rotate_calls(),
        vec![turn_id(42)],
        "rotate appelé avec keep_from = up_to du handoff"
    );
}

/// Pas de handoff ⇒ `floor = None` ⇒ skip : `rotate` jamais appelé même au-dessus du seuil.
#[tokio::test]
async fn rotate_skips_without_handoff() {
    let archive = Arc::new(FakeArchive::with_stats(10_000, u64::MAX));
    let uc = rotate_uc(archive.clone() as Arc<dyn ConversationArchive>, None);

    uc.execute(input()).await.expect("skip ok");

    assert!(
        archive.rotate_calls().is_empty(),
        "sans handoff ⇒ aucun rotate"
    );
}

/// Handoff présent mais stats SOUS les deux seuils ⇒ skip (pas de rotation).
#[tokio::test]
async fn rotate_skips_under_thresholds() {
    let archive = Arc::new(FakeArchive::with_stats(10, 1_000));
    let handoff = Handoff::new("r", turn_id(5), None);
    let uc = rotate_uc(
        archive.clone() as Arc<dyn ConversationArchive>,
        Some(handoff),
    );

    uc.execute(input()).await.expect("skip ok");

    assert!(archive.rotate_calls().is_empty(), "sous les seuils ⇒ skip");
}

/// Provider d'archive absent ⇒ no-op silencieux (best-effort), jamais une erreur.
#[tokio::test]
async fn rotate_without_archive_provider_is_noop() {
    let store = Arc::new(FakeHandoffStore(Mutex::new(Some(Handoff::new(
        "r",
        turn_id(5),
        None,
    ))))) as Arc<dyn HandoffStore>;
    let uc = RotateConversationLog::new(
        Arc::new(NoArchiveProvider),
        Arc::new(FakeHandoffProvider(store)),
    );
    uc.execute(input()).await.expect("no provider ⇒ Ok(())");
}

/// Une erreur store est **propagée typée** (`AppError::Store`) par le use case ; le
/// **swallowing best-effort** est la responsabilité du câblage (cf. test app-tauri).
#[tokio::test]
async fn rotate_propagates_typed_store_error() {
    let archive = Arc::new(FakeArchive::failing());
    let handoff = Handoff::new("r", turn_id(9), None);
    let uc = rotate_uc(archive as Arc<dyn ConversationArchive>, Some(handoff));

    let err = uc.execute(input()).await.expect_err("store error propagée");
    assert!(
        matches!(err, application::AppError::Store(_)),
        "erreur store typée, got {err:?}"
    );
}

// ---------------------------------------------------------------------------
// ReadConversationPage — DTO humain riche
// ---------------------------------------------------------------------------

/// La page mappe un DTO riche : texte **complet** (non borné), `text_len` correct,
/// mapping source Human/Agent, `has_more` et `next_anchor` (= dernier id de la page).
#[tokio::test]
async fn read_page_maps_rich_dto_with_full_text_and_sources() {
    let big = "Z".repeat(30_000);
    let turns = vec![
        turn(turn_id(1), InputSource::Human, TurnRole::Prompt, &big),
        turn(
            turn_id(2),
            InputSource::agent(agent_id(7)),
            TurnRole::Response,
            "réponse agent",
        ),
        turn(turn_id(3), InputSource::Human, TurnRole::Prompt, "suite"),
    ];
    let archive = Arc::new(FakeArchive::with_page(turns));
    let uc = ReadConversationPage::new(Arc::new(FakeArchiveProvider(archive)));

    // limit 2 ⇒ page [1,2], has_more (il reste le tour 3).
    let page = uc
        .execute(ReadConversationPageInput {
            project_root: root(),
            conversation: conv_id(1),
            cursor: PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            limit: 2,
        })
        .await
        .expect("page ok");

    assert_eq!(page.turns.len(), 2);
    // Texte complet préservé + text_len.
    assert_eq!(page.turns[0].text.chars().count(), 30_000);
    assert_eq!(page.turns[0].text_len, 30_000);
    // Mapping source : Human puis Agent{7}.
    assert_eq!(page.turns[0].source, TurnSource::Human);
    assert_eq!(
        page.turns[1].source,
        TurnSource::Agent {
            agent_id: agent_id(7)
        }
    );
    // has_more + next_anchor = dernier id de la page (tour 2).
    assert!(page.has_more, "il reste le tour 3");
    assert_eq!(page.next_anchor, Some(turn_id(2)));
}

/// Provider d'archive absent ⇒ page **vide** (best-effort), `next_anchor = None`.
#[tokio::test]
async fn read_page_without_provider_is_empty() {
    let uc = ReadConversationPage::new(Arc::new(NoArchiveProvider));
    let page = uc
        .execute(ReadConversationPageInput {
            project_root: root(),
            conversation: conv_id(1),
            cursor: PageCursor {
                anchor: None,
                direction: PageDirection::Forward,
            },
            limit: 50,
        })
        .await
        .expect("page ok");
    assert!(page.turns.is_empty() && !page.has_more && page.next_anchor.is_none());
}
