//! LOT D3 (ARCHITECTURE §17, ligne §17.9 D3) — tests du **chemin structuré** de
//! [`LaunchAgent`] et de la réconciliation A/B, 100 % via des fakes (jamais le vrai
//! claude/codex).
//!
//! Couvre, en injectant une fake [`AgentSessionFactory`] (+ fake [`AgentSession`])
//! via les builders additifs `with_structured(...)` :
//!
//! 1. **Routage `LaunchAgent`** : un profil porteur d'un `structured_adapter` + une
//!    factory câblée ⇒ `factory.start` appelé, session enregistrée dans
//!    [`StructuredSessions`] (retrouvable par `session_for_agent`), **aucun**
//!    `pty.spawn`, `AgentLaunched` publié, `LaunchAgentOutput.structured = Some(..)`
//!    avec les bons `agent_id`/`node_id`/`conversation_id` ; un profil non structuré
//!    suit le chemin PTY inchangé ; invariant « 1 session vivante/agent » côté
//!    structuré (rebind/idempotence, pas de 2e `start`).
//! 2. **Réconciliation A** (`ChangeAgentProfile`) : session structurée vivante ⇒
//!    `shutdown()` polymorphe (pas un kill PTY) puis relance via la factory ;
//!    session PTY vivante ⇒ A1 d'origine (kill PTY) inchangé ; la détection consulte
//!    les deux registres.
//! 3. **Réconciliation B** : `resolve_session_plan` renvoie `Resume{conversation_id}`
//!    pour un profil structuré dont la cellule porte une conversation (le runtime
//!    fake capture le `SessionPlan` reçu).
//!
//! Le fake `AgentSession` enregistre ses `shutdown()` dans un compteur partagé, et
//! le fake `AgentSessionFactory` enregistre chaque `start` (profil + plan de session)
//! — c'est ainsi qu'on prouve « start appelé une seule fois », « shutdown appelé »,
//! « le bon SessionPlan transmis ».

use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::ports::{
    AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
    ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
    IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext,
    ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream,
    RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId};
use uuid::Uuid;

use application::{
    ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions,
    TerminalSessions,
};

// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore)
// ---------------------------------------------------------------------------

#[derive(Default)]
struct ContextsInner {
    manifest: AgentManifest,
    contents: HashMap<String, String>,
}

#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);

impl FakeContexts {
    fn with_agent(agent: &Agent, content: &str) -> Self {
        let me = Self(Arc::new(Mutex::new(ContextsInner {
            manifest: AgentManifest {
                version: 1,
                entries: Vec::new(),
                orchestrator: None,
            },
            contents: HashMap::new(),
        })));
        {
            let mut inner = me.0.lock().unwrap();
            inner
                .manifest
                .entries
                .push(ManifestEntry::from_agent(agent));
            inner
                .contents
                .insert(agent.context_path.clone(), content.to_owned());
        }
        me
    }
    fn md_path_of(&self, agent: &AgentId) -> Option<String> {
        self.0
            .lock()
            .unwrap()
            .manifest
            .entries
            .iter()
            .find(|e| &e.agent_id == agent)
            .map(|e| e.md_path.clone())
    }
    fn profile_of(&self, agent: &AgentId) -> Option<ProfileId> {
        self.0
            .lock()
            .unwrap()
            .manifest
            .entries
            .iter()
            .find(|e| &e.agent_id == agent)
            .map(|e| e.profile_id)
    }
}

#[async_trait]
impl AgentContextStore for FakeContexts {
    async fn read_context(
        &self,
        _project: &Project,
        agent: &AgentId,
    ) -> Result<MarkdownDoc, StoreError> {
        let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        self.0
            .lock()
            .unwrap()
            .contents
            .get(&md_path)
            .cloned()
            .map(MarkdownDoc::new)
            .ok_or(StoreError::NotFound)
    }
    async fn write_context(
        &self,
        _project: &Project,
        agent: &AgentId,
        md: &MarkdownDoc,
    ) -> Result<(), StoreError> {
        let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        self.0
            .lock()
            .unwrap()
            .contents
            .insert(md_path, md.as_str().to_owned());
        Ok(())
    }
    async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
        Ok(self.0.lock().unwrap().manifest.clone())
    }
    async fn save_manifest(
        &self,
        _project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError> {
        self.0.lock().unwrap().manifest = manifest.clone();
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakeProfiles (ProfileStore)
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeProfiles(Arc<Vec<AgentProfile>>);

impl FakeProfiles {
    fn new(profiles: Vec<AgentProfile>) -> Self {
        Self(Arc::new(profiles))
    }
}

#[async_trait]
impl ProfileStore for FakeProfiles {
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
        Ok((*self.0).clone())
    }
    async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
        Ok(())
    }
    async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
        Ok(())
    }
    async fn is_configured(&self) -> Result<bool, StoreError> {
        Ok(true)
    }
    async fn mark_configured(&self) -> Result<(), StoreError> {
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakeStore (ProjectStore)
// ---------------------------------------------------------------------------

#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<Vec<Project>>>);

#[async_trait]
impl ProjectStore for FakeStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        Ok(self.0.lock().unwrap().clone())
    }
    async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
        self.0
            .lock()
            .unwrap()
            .iter()
            .find(|p| p.id == id)
            .cloned()
            .ok_or(StoreError::NotFound)
    }
    async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
        self.0.lock().unwrap().push(project.clone());
        Ok(())
    }
    async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
        Ok(())
    }
    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        Ok(Workspace::default())
    }
}

// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — HashMap-backed
// ---------------------------------------------------------------------------

#[derive(Default)]
struct FakeFsInner {
    files: HashMap<String, Vec<u8>>,
}

#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);

#[async_trait]
impl FileSystem for FakeFs {
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
        self.0
            .lock()
            .unwrap()
            .files
            .get(path.as_str())
            .cloned()
            .ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
    }
    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
        self.0
            .lock()
            .unwrap()
            .files
            .insert(path.as_str().to_owned(), data.to_vec());
        Ok(())
    }
    async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
        Ok(false)
    }
    async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
    async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
        Ok(Vec::new())
    }
    async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakeRuntime (AgentRuntime) — capture le SessionPlan reçu (B)
// ---------------------------------------------------------------------------

struct FakeRuntime {
    last_session: Arc<Mutex<Option<SessionPlan>>>,
}

impl FakeRuntime {
    fn new() -> Self {
        Self {
            last_session: Arc::new(Mutex::new(None)),
        }
    }
    fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
        Arc::clone(&self.last_session)
    }
}

#[async_trait]
impl AgentRuntime for FakeRuntime {
    async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
        Ok(true)
    }
    fn prepare_invocation(
        &self,
        profile: &AgentProfile,
        _ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        *self.last_session.lock().unwrap() = Some(session.clone());
        Ok(SpawnSpec {
            command: profile.command.clone(),
            args: profile.args.clone(),
            cwd: cwd.clone(),
            env: Vec::new(),
            context_plan: Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
            sandbox: None,
        })
    }
}

// ---------------------------------------------------------------------------
// FakePty (PtyPort) — records spawns and kills
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakePty {
    next_id: SessionId,
    spawns: Arc<Mutex<Vec<SpawnSpec>>>,
    kills: Arc<Mutex<Vec<SessionId>>>,
}

impl FakePty {
    fn new(next_id: SessionId) -> Self {
        Self {
            next_id,
            spawns: Arc::new(Mutex::new(Vec::new())),
            kills: Arc::new(Mutex::new(Vec::new())),
        }
    }
    fn spawn_count(&self) -> usize {
        self.spawns.lock().unwrap().len()
    }
    fn kills(&self) -> Vec<SessionId> {
        self.kills.lock().unwrap().clone()
    }
}

#[async_trait]
impl PtyPort for FakePty {
    async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
        self.spawns.lock().unwrap().push(spec);
        Ok(PtyHandle {
            session_id: self.next_id,
        })
    }
    fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
        Ok(())
    }
    fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
        Ok(())
    }
    fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
        Ok(Box::new(std::iter::empty()))
    }
    fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
        Ok(Vec::new())
    }
    async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }
    fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
        Ok(Some(ExitStatus { code: Some(0) }))
    }
    async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        self.kills.lock().unwrap().push(handle.session_id);
        Ok(ExitStatus { code: Some(0) })
    }
}

// ---------------------------------------------------------------------------
// FakeSkills / FakeRecall / SpyBus / SeqIds
// ---------------------------------------------------------------------------

#[derive(Clone, Default)]
struct FakeSkills;

#[async_trait]
impl SkillStore for FakeSkills {
    async fn list(&self, _s: SkillScope, _r: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
        Ok(Vec::new())
    }
    async fn get(
        &self,
        _s: SkillScope,
        _r: &ProjectPath,
        _id: SkillId,
    ) -> Result<Skill, StoreError> {
        Err(StoreError::NotFound)
    }
    async fn save(&self, _skill: &Skill, _r: &ProjectPath) -> Result<(), StoreError> {
        Ok(())
    }
    async fn delete(
        &self,
        _s: SkillScope,
        _r: &ProjectPath,
        _id: SkillId,
    ) -> Result<(), StoreError> {
        Ok(())
    }
}

#[derive(Clone, Default)]
struct FakeRecall;

#[async_trait]
impl MemoryRecall for FakeRecall {
    async fn recall(
        &self,
        _root: &ProjectPath,
        _query: &MemoryQuery,
    ) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
        Ok(Vec::new())
    }
}

#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
    fn events(&self) -> Vec<DomainEvent> {
        self.0.lock().unwrap().clone()
    }
}
impl EventBus for SpyBus {
    fn publish(&self, event: DomainEvent) {
        self.0.lock().unwrap().push(event);
    }
    fn subscribe(&self) -> EventStream {
        Box::new(std::iter::empty())
    }
}

struct SeqIds(Mutex<u128>);
impl SeqIds {
    fn new() -> Self {
        Self(Mutex::new(1))
    }
}
impl IdGenerator for SeqIds {
    fn new_uuid(&self) -> Uuid {
        let mut n = self.0.lock().unwrap();
        let id = Uuid::from_u128(*n);
        *n += 1;
        id
    }
}

// ---------------------------------------------------------------------------
// FakeAgentSession + FakeAgentSessionFactory (le cœur du chemin structuré)
// ---------------------------------------------------------------------------

/// Session structurée fake : id + conversation id figés, `shutdown()` enregistré
/// dans un compteur partagé (preuve du kill polymorphe), `send()` borné minimal.
struct FakeAgentSession {
    id: SessionId,
    conversation_id: Option<String>,
    shutdowns: Arc<AtomicUsize>,
}

#[async_trait]
impl AgentSession for FakeAgentSession {
    fn id(&self) -> SessionId {
        self.id
    }
    fn conversation_id(&self) -> Option<String> {
        self.conversation_id.clone()
    }
    async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
        let stream: ReplyStream = Box::new(
            vec![ReplyEvent::Final {
                content: prompt.to_owned(),
            }]
            .into_iter(),
        );
        Ok(stream)
    }
    async fn shutdown(&self) -> Result<(), AgentSessionError> {
        self.shutdowns.fetch_add(1, Ordering::SeqCst);
        Ok(())
    }
}

/// Fabrique fake : enregistre chaque `start` (profil + SessionPlan reçus) et rend une
/// [`FakeAgentSession`] avec l'id/conversation configurés. Partage le compteur de
/// `shutdown` avec les sessions qu'elle crée, pour prouver le kill polymorphe.
#[derive(Clone)]
struct FakeFactory {
    /// Id de session attribué aux sessions créées (incrémenté à chaque start).
    next_id: Arc<Mutex<u128>>,
    /// L'id de conversation moteur que la session exposera (`None` = neuf).
    conversation_id: Option<String>,
    /// Trace des `(command, SessionPlan)` reçus par `start`.
    starts: Arc<Mutex<Vec<(String, SessionPlan)>>>,
    /// Compteur de `shutdown()` partagé avec les sessions créées.
    shutdowns: Arc<AtomicUsize>,
}

impl FakeFactory {
    fn new(first_id: u128, conversation_id: Option<&str>) -> Self {
        Self {
            next_id: Arc::new(Mutex::new(first_id)),
            conversation_id: conversation_id.map(str::to_owned),
            starts: Arc::new(Mutex::new(Vec::new())),
            shutdowns: Arc::new(AtomicUsize::new(0)),
        }
    }
    fn start_count(&self) -> usize {
        self.starts.lock().unwrap().len()
    }
    fn starts(&self) -> Vec<(String, SessionPlan)> {
        self.starts.lock().unwrap().clone()
    }
    fn shutdown_count(&self) -> usize {
        self.shutdowns.load(Ordering::SeqCst)
    }
}

#[async_trait]
impl AgentSessionFactory for FakeFactory {
    fn supports(&self, profile: &AgentProfile) -> bool {
        profile.structured_adapter.is_some()
    }
    async fn start(
        &self,
        profile: &AgentProfile,
        _ctx: &PreparedContext,
        _cwd: &ProjectPath,
        session: &SessionPlan,
        _requester: Option<&str>,
        _env: &[(String, String)],
        _sandbox: Option<&domain::sandbox::SandboxPlan>,
        _structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
    ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
        self.starts
            .lock()
            .unwrap()
            .push((profile.command.clone(), session.clone()));
        let id = {
            let mut n = self.next_id.lock().unwrap();
            let id = SessionId::from_uuid(Uuid::from_u128(*n));
            *n += 1;
            id
        };
        Ok(Arc::new(FakeAgentSession {
            id,
            conversation_id: self.conversation_id.clone(),
            shutdowns: Arc::clone(&self.shutdowns),
        }))
    }
}

// ---------------------------------------------------------------------------
// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b)
// ---------------------------------------------------------------------------

use application::ProviderSessionProvider;
use domain::{ConversationId, ConversationParty, ProviderSessionStore};

/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
/// `set` can be made to fail (best-effort scenario) so we can prove a write error
/// never degrades the launch.
#[derive(Clone, Default)]
struct FakeProviderSessionStore {
    entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
    fail_set: bool,
    fail_get: bool,
}

impl FakeProviderSessionStore {
    fn new() -> Self {
        Self::default()
    }
    /// A store whose `set` always errors (best-effort proof).
    fn failing() -> Self {
        Self {
            entries: Arc::new(Mutex::new(HashMap::new())),
            fail_set: true,
            fail_get: false,
        }
    }
    /// A store whose `get` always errors (corrupted/unreadable providers.json).
    fn failing_get() -> Self {
        Self {
            entries: Arc::new(Mutex::new(HashMap::new())),
            fail_set: false,
            fail_get: true,
        }
    }
    /// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
    /// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
    /// resumable for that pair (mirrors what a previous P8b launch would have stored).
    fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
        self.entries
            .lock()
            .unwrap()
            .insert((conversation, provider.to_owned()), resumable_id.to_owned());
    }
    /// Observed resumable id for a `(conversation, provider)` couple, if any.
    fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
        self.entries
            .lock()
            .unwrap()
            .get(&(conversation, provider.to_owned()))
            .cloned()
    }
    fn len(&self) -> usize {
        self.entries.lock().unwrap().len()
    }
}

#[async_trait]
impl ProviderSessionStore for FakeProviderSessionStore {
    async fn get(
        &self,
        conversation: ConversationId,
        provider_id: &str,
    ) -> Result<Option<String>, StoreError> {
        if self.fail_get {
            return Err(StoreError::Serialization(
                "forced providers.json corruption".to_owned(),
            ));
        }
        Ok(self.get_sync(conversation, provider_id))
    }
    async fn set(
        &self,
        conversation: ConversationId,
        provider_id: &str,
        resumable_id: &str,
    ) -> Result<(), StoreError> {
        if self.fail_set {
            return Err(StoreError::Io("forced set failure".to_owned()));
        }
        self.entries.lock().unwrap().insert(
            (conversation, provider_id.to_owned()),
            resumable_id.to_owned(),
        );
        Ok(())
    }
}

/// [`ProviderSessionProvider`] that hands back the same store for any root (the
/// launch tests use a single project root).
#[derive(Clone)]
struct FakeProviderSessionProvider(Arc<FakeProviderSessionStore>);

impl ProviderSessionProvider for FakeProviderSessionProvider {
    fn provider_session_store_for(
        &self,
        _root: &ProjectPath,
    ) -> Option<Arc<dyn ProviderSessionStore>> {
        Some(Arc::clone(&self.0) as Arc<dyn ProviderSessionStore>)
    }
}

#[derive(Clone, Default)]
struct RootAwareProviderSessionProvider {
    stores: Arc<Mutex<HashMap<String, Arc<FakeProviderSessionStore>>>>,
}

impl RootAwareProviderSessionProvider {
    fn insert(&self, root: &str, store: Arc<FakeProviderSessionStore>) {
        self.stores.lock().unwrap().insert(root.to_owned(), store);
    }
}

impl ProviderSessionProvider for RootAwareProviderSessionProvider {
    fn provider_session_store_for(
        &self,
        root: &ProjectPath,
    ) -> Option<Arc<dyn ProviderSessionStore>> {
        self.stores
            .lock()
            .unwrap()
            .get(root.as_str())
            .cloned()
            .map(|store| store as Arc<dyn ProviderSessionStore>)
    }
}

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

const ROOT: &str = "/home/me/proj";

fn pid(n: u128) -> ProfileId {
    ProfileId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
    AgentId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
    SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
    NodeId::from_uuid(Uuid::from_u128(n))
}

fn project_id() -> ProjectId {
    ProjectId::from_uuid(Uuid::from_u128(1000))
}

fn project() -> Project {
    Project::new(
        project_id(),
        "demo",
        ProjectPath::new(ROOT).unwrap(),
        RemoteRef::local(),
        1_700_000_000_000,
    )
    .unwrap()
}

fn project_with_root(root: &str) -> Project {
    Project::new(
        project_id(),
        "demo",
        ProjectPath::new(root).unwrap(),
        RemoteRef::local(),
        1_700_000_000_000,
    )
    .unwrap()
}

/// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md.
fn structured_profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Claude Structuré",
        "claude",
        Vec::new(),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some("claude --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
    .with_structured_adapter(StructuredAdapter::Claude)
}

/// Profil **non structuré** (chemin PTY), convention file CLAUDE.md.
fn pty_profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Claude PTY",
        "claude",
        Vec::new(),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some("claude --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
}

fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent {
    Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap()
}

fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
    LaunchAgentInput {
        project: project(),
        agent_id,
        rows: 24,
        cols: 80,
        node_id: None,
        conversation_id: None,
        mcp_runtime: None,
        allow_structured_alongside_pty: false,
    }
}

/// Inserts a live PTY agent session into the registry, pinned on `node`.
fn seed_live_pty_session(
    sessions: &TerminalSessions,
    agent_id: AgentId,
    node: NodeId,
    session_id: SessionId,
) {
    let size = PtySize::new(24, 80).unwrap();
    let mut session = domain::TerminalSession::starting(
        session_id,
        node,
        ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(),
        SessionKind::Agent { agent_id },
        size,
    );
    session.status = domain::SessionStatus::Running;
    sessions.insert_in_project(project_id(), PtyHandle { session_id }, session);
}

// ---------------------------------------------------------------------------
// Fixture (LaunchAgent câblé structuré)
// ---------------------------------------------------------------------------

struct LaunchFixture {
    launch: Arc<LaunchAgent>,
    agent: Agent,
    pty: FakePty,
    bus: SpyBus,
    sessions: Arc<TerminalSessions>,
    structured: Arc<StructuredSessions>,
    factory: FakeFactory,
    session_probe: Arc<Mutex<Option<SessionPlan>>>,
}

/// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory.
fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile]);
    let runtime = FakeRuntime::new();
    let session_probe = runtime.session_probe();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let structured = Arc::new(StructuredSessions::new());
    let bus = SpyBus::default();
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(runtime),
        Arc::new(fs),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    .with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
    LaunchFixture {
        launch: Arc::new(launch),
        agent,
        pty,
        bus,
        sessions,
        structured,
        factory,
        session_probe,
    }
}

// ===========================================================================
// 1. Routage LaunchAgent : chemin structuré
// ===========================================================================

/// Profil structuré + factory câblée ⇒ `factory.start` appelé une fois, session
/// enregistrée dans `StructuredSessions`, AUCUN `pty.spawn`, `AgentLaunched` publié,
/// `output.structured = Some(descriptor)` avec les bons champs.
#[tokio::test]
async fn structured_launch_starts_session_registers_no_pty_spawn() {
    // Factory : 1re session id = 500, conversation moteur "engine-conv".
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(structured_profile(pid(9)), factory);

    let mut input = launch_input(f.agent.id);
    input.node_id = Some(nid(3));
    let out = f.launch.execute(input).await.expect("structured launch");

    // factory.start appelé exactement une fois.
    assert_eq!(f.factory.start_count(), 1, "factory.start called once");
    // AUCUN spawn PTY.
    assert_eq!(f.pty.spawn_count(), 0, "no pty spawn on structured path");

    // La session est enregistrée dans le registre structuré, retrouvable par agent.
    let registered = f
        .structured
        .session_for_agent_in_project(project_id(), &f.agent.id)
        .expect("structured session registered");
    assert_eq!(registered.id(), sid(500), "session id is the factory's");
    assert_eq!(
        f.structured
            .node_for_agent_in_project(project_id(), &f.agent.id),
        Some(nid(3))
    );
    // Rien côté registre PTY.
    assert!(f
        .sessions
        .session_for_agent_in_project(project_id(), &f.agent.id)
        .is_none());

    // AgentLaunched publié avec l'id de session structurée.
    assert_eq!(
        f.bus.events(),
        vec![DomainEvent::AgentLaunched {
            agent_id: f.agent.id,
            session_id: sid(500),
        }]
    );

    // output.structured renseigné avec les bons agent/node/conversation.
    let desc = out.structured.expect("structured descriptor present");
    assert_eq!(desc.session_id, sid(500));
    assert_eq!(desc.agent_id, f.agent.id);
    assert_eq!(desc.node_id, nid(3));
    assert_eq!(desc.conversation_id.as_deref(), Some("engine-conv"));

    // Le snapshot de session reste cohérent (kind = Agent, id = celui de la session).
    assert_eq!(out.session.id, sid(500));
    assert!(matches!(
        out.session.kind,
        SessionKind::Agent { agent_id } if agent_id == f.agent.id
    ));
    // P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id
    // moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(project, User, agent)`.
    let expected_pair = ConversationId::for_project_pair(
        project_id(),
        ConversationParty::User,
        ConversationParty::agent(f.agent.id),
    );
    assert_eq!(
        out.assigned_conversation_id.as_deref(),
        Some(expected_pair.to_string().as_str()),
        "cell carries the IdeA pair id (pivot logique), not the engine resumable"
    );
    // L'id de session MOTEUR (resumable provider) part dans le cache séparé.
    assert_eq!(
        out.engine_session_id.as_deref(),
        Some("engine-conv"),
        "engine resumable id is cached separately from the pair key"
    );
}

/// Profil **non structuré** (même câblage structuré présent) ⇒ chemin PTY inchangé :
/// `pty.spawn` appelé, factory jamais sollicitée, `output.structured = None`.
#[tokio::test]
async fn non_structured_profile_takes_pty_path_unchanged() {
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(pty_profile(pid(9)), factory);

    let out = f
        .launch
        .execute(launch_input(f.agent.id))
        .await
        .expect("pty launch");

    // PTY spawn appelé, factory jamais sollicitée.
    assert_eq!(f.pty.spawn_count(), 1, "pty path spawns");
    assert_eq!(
        f.factory.start_count(),
        0,
        "factory not called for pty profile"
    );

    // Session côté registre PTY, rien côté structuré.
    assert_eq!(
        f.sessions
            .session_for_agent_in_project(project_id(), &f.agent.id),
        Some(sid(777))
    );
    assert!(f
        .structured
        .session_for_agent_in_project(project_id(), &f.agent.id)
        .is_none());

    // output.structured = None ; session PTY classique.
    assert!(
        out.structured.is_none(),
        "no structured descriptor on pty path"
    );
    assert_eq!(out.session.id, sid(777));
}

/// Invariant « 1 agent = 1 employé » côté structuré (R0a) : un **lancement neuf**
/// (sans `conversation_id`) sur une **autre** cellule B d'un agent structuré déjà
/// vivant en cellule A est un second lancement ⇒ **refus**
/// `AppError::AgentAlreadyRunning { node_id: A }`, **pas** de 2e `factory.start`,
/// une seule session structurée vivante.
#[tokio::test]
async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(structured_profile(pid(9)), factory);

    // 1er lancement sur la cellule A.
    let host = nid(1);
    let mut first = launch_input(f.agent.id);
    first.node_id = Some(host);
    f.launch.execute(first).await.expect("first launch");
    assert_eq!(f.factory.start_count(), 1);
    assert_eq!(f.structured.len(), 1);

    // Lancement NEUF (conversation_id: None) dans une cellule B ⇒ refus.
    let mut second = launch_input(f.agent.id);
    let target = nid(2);
    second.node_id = Some(target);
    let err = second_launch_err(&f, second).await;

    match err {
        application::AppError::AgentAlreadyRunning { agent_id, node_id } => {
            assert_eq!(agent_id, f.agent.id, "reports the live agent");
            assert_eq!(node_id, host, "reports the live HOST node A, not target B");
        }
        other => panic!("expected AgentAlreadyRunning, got {other:?}"),
    }

    assert_eq!(
        f.factory.start_count(),
        1,
        "no second factory.start on a refused launch"
    );
    assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
    assert_eq!(
        f.structured.len(),
        1,
        "still a single live structured session"
    );
    assert_eq!(
        f.structured
            .node_for_agent_in_project(project_id(), &f.agent.id),
        Some(host),
        "session stays pinned on its host node A"
    );
}

/// **Réattache légitime même node (R0a, structuré)** : relancer sur la **même**
/// cellule hôte ⇒ rebind, pas d'erreur, pas de 2e `factory.start`, même session.
#[tokio::test]
async fn structured_relaunch_same_node_rebinds_no_second_start() {
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(structured_profile(pid(9)), factory);

    let host = nid(1);
    let mut first = launch_input(f.agent.id);
    first.node_id = Some(host);
    f.launch.execute(first).await.expect("first launch");
    assert_eq!(f.factory.start_count(), 1);

    // Re-ouverture de la MÊME cellule.
    let mut again = launch_input(f.agent.id);
    again.node_id = Some(host);
    let out = f.launch.execute(again).await.expect("same-node rebind");

    assert_eq!(f.factory.start_count(), 1, "no second factory.start");
    assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
    assert_eq!(f.structured.len(), 1, "single live structured session");
    assert_eq!(
        f.structured
            .node_for_agent_in_project(project_id(), &f.agent.id),
        Some(host)
    );
    let desc = out.structured.expect("descriptor on rebind");
    assert_eq!(desc.session_id, sid(500), "same live session id");
    assert_eq!(desc.node_id, host);
}

/// **Réattache explicite par conversation (R0a, structuré)** : relancer sur une
/// **autre** cellule B mais avec un `conversation_id` ⇒ rebind légitime de la vue,
/// pas d'erreur, pas de 2e `factory.start`, même session, vue déplacée sur B.
#[tokio::test]
async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(structured_profile(pid(9)), factory);

    let host = nid(1);
    let mut first = launch_input(f.agent.id);
    first.node_id = Some(host);
    f.launch.execute(first).await.expect("first launch");
    assert_eq!(f.factory.start_count(), 1);

    // Cellule B + signal de réattache explicite.
    let mut second = launch_input(f.agent.id);
    let target = nid(2);
    second.node_id = Some(target);
    second.conversation_id = Some("conv-live".to_owned());
    let out = f.launch.execute(second).await.expect("explicit reattach");

    assert_eq!(
        f.factory.start_count(),
        1,
        "no second factory.start on explicit reattach"
    );
    assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
    assert_eq!(
        f.structured.len(),
        1,
        "still a single live structured session"
    );
    assert_eq!(
        f.structured
            .node_for_agent_in_project(project_id(), &f.agent.id),
        Some(target)
    );
    let desc = out.structured.expect("descriptor on rebind");
    assert_eq!(desc.session_id, sid(500), "same live session id");
    assert_eq!(desc.node_id, target);
}

/// Helper: executes a launch that is expected to be refused, returning the error.
async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError {
    f.launch
        .execute(input)
        .await
        .expect_err("fresh second launch elsewhere is refused")
}

// ===========================================================================
// 2. Réconciliation A : ChangeAgentProfile (kill polymorphe)
// ===========================================================================

/// Câble un `ChangeAgentProfile.with_structured(...)` partageant les mêmes registres
/// (PTY + structuré) et la même factory que le `LaunchAgent` composé.
struct SwapFixture {
    swap: ChangeAgentProfile,
    contexts: FakeContexts,
    pty: FakePty,
    sessions: Arc<TerminalSessions>,
    structured: Arc<StructuredSessions>,
    factory: FakeFactory,
}

/// Wires the swap use case. Both `pid(1)` (current) and `pid(2)` (target) are known;
/// `pid(2)` is structured so a relaunch routes through the factory.
fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) -> SwapFixture {
    let target = if target_structured {
        structured_profile(pid(2))
    } else {
        pty_profile(pid(2))
    };
    let profiles_vec = vec![structured_profile(pid(1)), target];

    let contexts = FakeContexts::with_agent(agent, "# persona");
    let profiles = FakeProfiles::new(profiles_vec);
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let structured = Arc::new(StructuredSessions::new());
    let bus = SpyBus::default();

    // Register the project so ProjectStore::load_project resolves (for conv cleanup).
    {
        let mut v = store.0.lock().unwrap();
        v.push(project());
    }

    let launch = LaunchAgent::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles.clone()),
        Arc::new(FakeRuntime::new()),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    .with_structured(Arc::new(factory.clone()), Arc::clone(&structured));

    let swap = ChangeAgentProfile::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles),
        Arc::new(store),
        Arc::new(fs),
        Arc::clone(&sessions),
        Arc::new(pty.clone()),
        Arc::new(launch),
        Arc::new(bus),
    )
    .with_structured(Arc::clone(&structured));

    SwapFixture {
        swap,
        contexts,
        pty,
        sessions,
        structured,
        factory,
    }
}

fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput {
    ChangeAgentProfileInput {
        project: project(),
        agent_id,
        profile_id,
        rows: 24,
        cols: 80,
    }
}

/// Agent avec session **structurée** vivante ⇒ changement de profil ⇒ `shutdown()`
/// appelé sur la session structurée (PAS un kill PTY), puis relance via la factory.
#[tokio::test]
async fn swap_structured_live_session_shuts_down_then_relaunches() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    // La factory crée la session de relance (id 600) ; la session vivante initiale
    // partage le même compteur de shutdown via la factory.
    let factory = FakeFactory::new(600, Some("relaunch-conv"));
    let f = swap_fixture(&agent, true, factory);

    // Pré-seed : une session structurée vivante (id 500) sur la cellule N, via la
    // MÊME factory pour partager le compteur de shutdown.
    let host = nid(5);
    {
        // Démarre une session "manuellement" pour la pré-seeder dans le registre.
        let profile = structured_profile(pid(1));
        let ctx = PreparedContext {
            content: MarkdownDoc::new("# persona"),
            relative_path: "agents/backend.md".to_owned(),
            project_root: ROOT.to_owned(),
        };
        let cwd = ProjectPath::new(ROOT).unwrap();
        let session = f
            .factory
            .start(
                &profile,
                &ctx,
                &cwd,
                &SessionPlan::None,
                None,
                &[],
                None,
                None,
            )
            .await
            .expect("seed structured session");
        f.structured
            .insert_in_project(project_id(), session, agent.id, host);
    }
    // La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on
    // comptera les start APRÈS, donc on mémorise la base.
    let starts_before = f.factory.start_count();
    assert_eq!(starts_before, 1, "seed used one start");
    assert_eq!(f.structured.len(), 1);

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("hot swap succeeds");

    // shutdown() appelé sur la session structurée (kill polymorphe), PAS de kill PTY.
    assert_eq!(
        f.factory.shutdown_count(),
        1,
        "structured session shut down exactly once"
    );
    assert!(
        f.pty.kills().is_empty(),
        "no PTY kill for a structured live session"
    );
    assert_eq!(
        f.pty.spawn_count(),
        0,
        "no PTY spawn (target is structured)"
    );

    // Relance via la factory : un nouveau start (donc total 2).
    assert_eq!(
        f.factory.start_count(),
        2,
        "exactly one relaunch start after the seed"
    );
    // La nouvelle session structurée (id 600) est enregistrée sur la même cellule.
    // `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession)
    // — on vérifie donc le registre structuré + le snapshot.
    // Seed consumed id 600; the relaunch's session is the factory's next id (601).
    let relaunched = out
        .relaunched
        .expect("a live structured agent is relaunched");
    assert_eq!(
        relaunched.id,
        sid(601),
        "relaunch adopts the new structured id"
    );
    assert_eq!(
        relaunched.node_id, host,
        "relaunch reopens in the same cell"
    );
    assert_eq!(
        f.structured
            .session_id_for_agent_in_project(project_id(), &agent.id),
        Some(sid(601))
    );
    assert_eq!(
        f.structured
            .node_for_agent_in_project(project_id(), &agent.id),
        Some(host)
    );
    assert_eq!(
        f.structured.len(),
        1,
        "single live structured session after swap"
    );
    // Manifeste muté vers le nouveau profil.
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}

/// Agent avec session **PTY** vivante ⇒ comportement A1 d'origine (kill PTY)
/// inchangé (non-régression), même quand le registre structuré est branché.
#[tokio::test]
async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    // Cible PTY (pid(2) non structuré) ⇒ relance par spawn PTY.
    let factory = FakeFactory::new(600, None);
    let f = swap_fixture(&agent, false, factory);

    // Pré-seed : session PTY vivante (id 42) sur la cellule N.
    let host = nid(5);
    seed_live_pty_session(&f.sessions, agent.id, host, sid(42));

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("hot swap succeeds");

    // A1 d'origine : le PTY vivant est tué.
    assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
    // Aucune session structurée n'a été créée ni shutdown.
    assert_eq!(
        f.factory.start_count(),
        0,
        "no structured start on pty swap"
    );
    assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown");
    // Relance PTY : un spawn, même cellule.
    assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
    let relaunched = out.relaunched.expect("a live agent is relaunched");
    assert_eq!(
        relaunched.node_id, host,
        "relaunch reopens in the same cell"
    );
    assert_eq!(relaunched.id, sid(777));
    // The relaunched session lives in the PTY registry, not the structured one.
    assert_eq!(
        f.sessions
            .session_for_agent_in_project(project_id(), &agent.id),
        Some(sid(777))
    );
    assert!(f
        .structured
        .session_for_agent_in_project(project_id(), &agent.id)
        .is_none());
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}

/// Détection de session vivante : un agent **dead** (aucune session dans aucun des
/// deux registres) ⇒ pas de kill, pas de shutdown, pas de relance ; manifeste muté.
#[tokio::test]
async fn swap_dead_agent_no_kill_no_shutdown_no_relaunch() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let factory = FakeFactory::new(600, Some("c"));
    let f = swap_fixture(&agent, true, factory);

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert!(out.relaunched.is_none());
    assert!(f.structured.is_empty(), "no structured session created");
    assert!(f.pty.kills().is_empty());
    assert_eq!(f.factory.shutdown_count(), 0);
    assert_eq!(f.factory.start_count(), 0, "nothing to relaunch");
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}

// ===========================================================================
// 3. Réconciliation B : resolve_session_plan pour un profil structuré
// ===========================================================================

/// Une cellule structurée portant une conversation ⇒ le plan de session transmis au
/// runtime est `Resume{conversation_id}` (le runtime fake le capture), même sans
/// bloc `session` sur le profil (la reprise structurée dépend de l'adapter).
#[tokio::test]
async fn structured_profile_with_cell_conversation_resolves_to_resume() {
    let factory = FakeFactory::new(500, Some("engine-conv"));
    let f = launch_fixture(structured_profile(pid(9)), factory);

    let mut input = launch_input(f.agent.id);
    input.conversation_id = Some("conv-existing".to_owned());
    f.launch.execute(input).await.expect("launch resumes");

    // Le runtime (prepare_invocation) a reçu un SessionPlan::Resume avec l'id cellule.
    assert_eq!(
        *f.session_probe.lock().unwrap(),
        Some(SessionPlan::Resume {
            conversation_id: "conv-existing".to_owned()
        }),
        "structured profile with a cell conversation must resume"
    );
    // Le plan Resume est aussi celui transmis à la factory.start (preuve bout-en-bout).
    let starts = f.factory.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::Resume {
            conversation_id: "conv-existing".to_owned()
        }
    );
}

/// Une cellule structurée **neuve** (pas de conversation) sur un profil sans bloc
/// `session` ⇒ `SessionPlan::None` (rien à reprendre ; l'id moteur sera capté au 1er
/// tour).
#[tokio::test]
async fn structured_profile_fresh_cell_resolves_to_none() {
    let factory = FakeFactory::new(500, None);
    let f = launch_fixture(structured_profile(pid(9)), factory);

    f.launch
        .execute(launch_input(f.agent.id))
        .await
        .expect("fresh launch");

    let starts = f.factory.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "fresh structured cell without a session block plans None"
    );
}

// ===========================================================================
// LOT P8b — écriture de `providers.json` au lancement structuré
//
// Prouve `persist_provider_session` (best-effort) : après un lancement structuré
// exposant un id de session moteur, IdeA range `(pair, provider_key) →
// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de
// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont
// couvertes — le lancement réussit toujours.
// ===========================================================================

/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé
/// provider `"codex"`.
fn structured_codex_profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Codex Structuré",
        "codex",
        Vec::new(),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        Some("codex --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
    .with_structured_adapter(StructuredAdapter::Codex)
}

/// Profil **structuré OpenCode** pour prouver la clé provider `"opencode"`.
fn structured_opencode_profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "OpenCode Structuré",
        "opencode",
        Vec::new(),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        Some("opencode --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
    .with_structured_adapter(StructuredAdapter::OpenCode)
}

/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
/// over the given profile/factory, returning the launcher, the agent and the
/// observable provider store. When `provider` is `None`, no provider is wired
/// (legacy path).
fn launch_fixture_p8b(
    profile: AgentProfile,
    factory: FakeFactory,
    store: Option<Arc<FakeProviderSessionStore>>,
) -> (Arc<LaunchAgent>, Agent) {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile]);
    let runtime = FakeRuntime::new();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let structured = Arc::new(StructuredSessions::new());
    let bus = SpyBus::default();
    let mut launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(runtime),
        Arc::new(fs),
        Arc::new(pty),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    .with_structured(Arc::new(factory), Arc::clone(&structured));
    if let Some(store) = store {
        let provider =
            Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
        launch = launch.with_provider_session_provider(provider);
    }
    (Arc::new(launch), agent)
}

/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant
/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id`
/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de
/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée).
#[tokio::test]
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, Some("engine-xyz"));
    let (launch, agent) = launch_fixture_p8b(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    // La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch.execute(input).await.expect("structured launch");

    assert_eq!(
        store.get_sync(pair, "claude").as_deref(),
        Some("engine-xyz"),
        "engine resumable stored under (pair, claude)"
    );
    assert_eq!(store.len(), 1, "exactly one entry written");
    // La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas
    // l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien.
    assert!(
        store.get_sync(pair, "codex").is_none(),
        "nothing written under a different provider key"
    );
}

/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider
/// `"codex"`.
#[tokio::test]
async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, Some("engine-codex"));
    let (launch, agent) = launch_fixture_p8b(
        structured_codex_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(456));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch.execute(input).await.expect("structured launch");

    assert_eq!(
        store.get_sync(pair, "codex").as_deref(),
        Some("engine-codex"),
        "engine resumable stored under (pair, codex)"
    );
    assert_eq!(store.len(), 1);
}

/// **provider_key correct** : test direct du contrat de persistance.
#[test]
fn p8b_provider_key_mapping_is_stable() {
    assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
    assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
    assert_eq!(StructuredAdapter::OpenCode.provider_key(), "opencode");
}

/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une
/// copie pour le prouver).
#[tokio::test]
async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, Some("engine-xyz"));
    // store NON câblé (None) ⇒ aucune écriture possible.
    let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None);

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch
        .execute(input)
        .await
        .expect("launch ok without provider");

    assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
}

/// **No-op sans id moteur** : la session fake n'expose aucun id
/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit.
#[tokio::test]
async fn p8b_no_engine_id_writes_nothing_launch_ok() {
    let store = Arc::new(FakeProviderSessionStore::new());
    // Factory avec conversation_id None ⇒ session.conversation_id() == None.
    let factory = FakeFactory::new(500, None);
    let (launch, agent) = launch_fixture_p8b(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch
        .execute(input)
        .await
        .expect("launch ok without engine id");

    assert_eq!(
        store.len(),
        0,
        "no engine session id ⇒ persist skipped, launch unaffected"
    );
}

/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand
/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent,
/// engine_session_id exposé).
#[tokio::test]
async fn p8b_store_set_error_does_not_fail_launch() {
    let store = Arc::new(FakeProviderSessionStore::failing());
    let factory = FakeFactory::new(500, Some("engine-xyz"));
    let (launch, agent) = launch_fixture_p8b(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    // Le lancement réussit malgré l'échec d'écriture du resumable.
    let out = launch
        .execute(input)
        .await
        .expect("launch must succeed despite store set failure");

    // Sortie non dégradée : descripteur structuré + id moteur toujours exposés.
    let desc = out.structured.expect("structured descriptor still present");
    assert_eq!(desc.session_id, sid(500));
    assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz"));
    // Le store n'a (logiquement) rien persisté.
    assert_eq!(store.len(), 0, "failing set wrote nothing");
}

// ===========================================================================
// LOT P8c — routage du `--resume` moteur via providers.json
//
// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le
// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par
// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le
// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) —
// **jamais** l'id de paire lui-même n'est passé en `--resume`.
//
// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par
// `structured_profile_with_cell_conversation_resolves_to_resume`,
// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les
// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store.
// ===========================================================================

/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant
/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`.
fn launch_fixture_p8c(
    profile: AgentProfile,
    factory: FakeFactory,
    store: Option<Arc<FakeProviderSessionStore>>,
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
    let observed = factory.clone();
    let (launch, agent) = launch_fixture_p8b(profile, factory, store);
    (launch, agent, observed)
}

fn launch_fixture_with_provider(
    profile: AgentProfile,
    factory: FakeFactory,
    provider: Arc<dyn ProviderSessionProvider>,
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
    let observed = factory.clone();
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile]);
    let runtime = FakeRuntime::new();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let structured = Arc::new(StructuredSessions::new());
    let bus = SpyBus::default();
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(runtime),
        Arc::new(fs),
        Arc::new(pty),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    .with_structured(Arc::new(factory), Arc::clone(&structured))
    .with_provider_session_provider(provider);
    (Arc::new(launch), agent, observed)
}

/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
/// **et surtout PAS** l'id de paire.
#[tokio::test]
async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
    let store = Arc::new(FakeProviderSessionStore::new());
    // Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    // Pré-remplit le store : (pair, "claude") → "engine-x".
    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    store.seed_sync(pair, "claude", "engine-x");

    // La cellule porte l'id de **paire** (UUID), jamais l'id moteur.
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch
        .execute(input)
        .await
        .expect("structured launch resumes");

    // Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
    let starts = observed.starts();
    assert_eq!(starts.len(), 1, "factory.start called once");
    assert_eq!(
        starts[0].1,
        SessionPlan::Resume {
            conversation_id: "engine-x".to_owned()
        },
        "resume must carry the engine resumable id from providers.json"
    );
    // GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume.
    match &starts[0].1 {
        SessionPlan::Resume { conversation_id } => {
            assert_ne!(
                conversation_id,
                &pair.to_string(),
                "the pair id must NEVER be passed as the engine --resume"
            );
        }
        other => panic!("expected Resume, got {other:?}"),
    }
}

/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** :
/// même contrat, clé provider `"codex"`.
#[tokio::test]
async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_codex_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(456));
    store.seed_sync(pair, "codex", "engine-codex-x");

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch
        .execute(input)
        .await
        .expect("structured codex launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::Resume {
            conversation_id: "engine-codex-x".to_owned()
        },
        "codex resume reads its own provider key"
    );
}

/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que
/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve
/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire).
#[tokio::test]
async fn p8c_provider_key_mismatch_falls_back_to_none() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    // Engine id rangé sous une AUTRE clé provider.
    store.seed_sync(pair, "codex", "engine-codex");

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch.execute(input).await.expect("structured launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "a claude profile must not pick up a codex-keyed resumable"
    );
}

/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de
/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier
/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis.
#[tokio::test]
async fn p8c_structured_store_empty_resolves_to_none() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch.execute(input).await.expect("structured launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "empty store ⇒ clean first launch, never the pair id"
    );
}

/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à
/// chercher ⇒ `SessionPlan::None`.
#[tokio::test]
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    // Cellule neuve : conversation_id = None.
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("fresh structured launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "no cell id ⇒ nothing to look up ⇒ None"
    );
}

/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne
/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒
/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`.
#[tokio::test]
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
    let store = Arc::new(FakeProviderSessionStore::new());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let mut input = launch_input(agent.id);
    input.conversation_id = Some("not-a-uuid".to_owned());

    launch.execute(input).await.expect("structured launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "a non-UUID pair id must degrade to None, never resume the raw string"
    );
}

#[tokio::test]
async fn p8c_store_get_error_fails_closed_to_fresh_session() {
    let store = Arc::new(FakeProviderSessionStore::failing_get());
    let factory = FakeFactory::new(500, None);
    let (launch, agent, observed) = launch_fixture_p8c(
        structured_opencode_profile(pid(9)),
        factory,
        Some(Arc::clone(&store)),
    );

    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(pair.to_string());

    launch.execute(input).await.expect("structured launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 1);
    assert_eq!(
        starts[0].1,
        SessionPlan::None,
        "corrupted provider session store must start fresh, never resume ambiguously"
    );
}

#[tokio::test]
async fn p8c_opencode_same_pair_and_provider_are_isolated_by_project_root() {
    let store_a = Arc::new(FakeProviderSessionStore::new());
    let store_b = Arc::new(FakeProviderSessionStore::new());
    let provider = RootAwareProviderSessionProvider::default();
    provider.insert("/home/me/project-a", Arc::clone(&store_a));
    provider.insert("/home/me/project-b", Arc::clone(&store_b));
    let provider = Arc::new(provider) as Arc<dyn ProviderSessionProvider>;

    let factory = FakeFactory::new(500, None);
    let (launch_a, agent_a, observed) = launch_fixture_with_provider(
        structured_opencode_profile(pid(9)),
        factory.clone(),
        Arc::clone(&provider),
    );
    let (launch_b, agent_b, _) =
        launch_fixture_with_provider(structured_opencode_profile(pid(9)), factory, provider);
    let pair = ConversationId::from_uuid(Uuid::from_u128(123));
    store_a.seed_sync(pair, "opencode", "engine-project-a");
    store_b.seed_sync(pair, "opencode", "engine-project-b");

    let mut input_a = launch_input(agent_a.id);
    input_a.project = project_with_root("/home/me/project-a");
    input_a.conversation_id = Some(pair.to_string());
    launch_a.execute(input_a).await.expect("project A launch");

    let mut input_b = launch_input(agent_b.id);
    input_b.project = project_with_root("/home/me/project-b");
    input_b.conversation_id = Some(pair.to_string());
    launch_b.execute(input_b).await.expect("project B launch");

    let starts = observed.starts();
    assert_eq!(starts.len(), 2);
    assert_eq!(
        starts[0].1,
        SessionPlan::Resume {
            conversation_id: "engine-project-a".to_owned()
        }
    );
    assert_eq!(
        starts[1].1,
        SessionPlan::Resume {
            conversation_id: "engine-project-b".to_owned()
        },
        "project B must read its own opencode token, never project A's"
    );
}
