//! B1 tests for [`ListResumableAgents`] (ARCHITECTURE §15.2, §15.4 line B1).
//!
//! `ListResumableAgents` is a **read-only** inventory built at project reopen:
//! it walks every persisted layout's `agent_leaves()`, reads each hosting
//! `LeafCell`'s `(conversation_id, agent_was_running)` via the pure
//! `LayoutTree::leaf` accessor, keeps only the leaves passing the §15.2 filter
//! (`was_running || conversation_id.is_some()`), resolves the display name via
//! the manifest, and derives `resume_supported` from the agent's profile
//! (`SessionStrategy` present ⇒ `true`). It is **best-effort**: any read failure
//! degrades to an empty list, never an `Err`, never a panic.
//!
//! Every port is faked in-memory (100 % without real I/O), reusing the harness
//! style of `snapshot_running_agents.rs` (FakeFs + `seed_layouts`) and
//! `change_agent_profile.rs` (FakeContexts + FakeProfiles + FakeStore).

use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::ports::{
    AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath,
    StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{
    AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell,
    NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild,
};
use uuid::Uuid;

use application::{ListResumableAgents, ListResumableAgentsInput};

// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — HashMap-backed, serves layouts.json
// ---------------------------------------------------------------------------

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

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

impl FakeFs {
    fn put(&self, path: &str, data: &[u8]) {
        self.0
            .lock()
            .unwrap()
            .files
            .insert(path.to_owned(), data.to_vec());
    }
}

#[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> {
        let inner = self.0.lock().unwrap();
        Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
    }
    async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
        self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
        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(())
    }
}

// ---------------------------------------------------------------------------
// FakeStore (ProjectStore) — holds the project
// ---------------------------------------------------------------------------

#[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())
    }
}

// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — manifest only (name + profile_id)
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeContexts {
    manifest: Arc<Mutex<AgentManifest>>,
    /// When true, `load_manifest` fails (best-effort path test).
    fail: bool,
}

impl FakeContexts {
    fn new(entries: Vec<ManifestEntry>) -> Self {
        Self {
            manifest: Arc::new(Mutex::new(AgentManifest {
                version: 1,
                entries,
                orchestrator: None,
            })),
            fail: false,
        }
    }
    fn failing() -> Self {
        Self {
            manifest: Arc::new(Mutex::new(AgentManifest {
                version: 1,
                entries: Vec::new(),
                orchestrator: None,
            })),
            fail: true,
        }
    }
}

#[async_trait]
impl AgentContextStore for FakeContexts {
    async fn read_context(
        &self,
        _project: &Project,
        _agent: &AgentId,
    ) -> Result<MarkdownDoc, StoreError> {
        Err(StoreError::NotFound)
    }
    async fn write_context(
        &self,
        _project: &Project,
        _agent: &AgentId,
        _md: &MarkdownDoc,
    ) -> Result<(), StoreError> {
        Ok(())
    }
    async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
        if self.fail {
            return Err(StoreError::NotFound);
        }
        Ok(self.manifest.lock().unwrap().clone())
    }
    async fn save_manifest(
        &self,
        _project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError> {
        *self.manifest.lock().unwrap() = manifest.clone();
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakeProfiles (ProfileStore) — fixed list, optionally failing on `list`
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeProfiles {
    profiles: Arc<Vec<AgentProfile>>,
    fail: bool,
}

impl FakeProfiles {
    fn new(profiles: Vec<AgentProfile>) -> Self {
        Self {
            profiles: Arc::new(profiles),
            fail: false,
        }
    }
    fn failing() -> Self {
        Self {
            profiles: Arc::new(Vec::new()),
            fail: true,
        }
    }
}

#[async_trait]
impl ProfileStore for FakeProfiles {
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
        if self.fail {
            return Err(StoreError::NotFound);
        }
        Ok((*self.profiles).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(())
    }
}

// ---------------------------------------------------------------------------
// Helpers / builders
// ---------------------------------------------------------------------------

const ROOT: &str = "/home/me/proj";
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";

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 nid(n: u128) -> NodeId {
    NodeId::from_uuid(Uuid::from_u128(n))
}
fn lid(n: u128) -> LayoutId {
    LayoutId::from_uuid(Uuid::from_u128(n))
}
fn proj_id(n: u128) -> ProjectId {
    ProjectId::from_uuid(Uuid::from_u128(n))
}

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

/// A profile WITH a resumable `SessionStrategy` (resume_flag present).
fn profile_with_session(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Resumable CLI",
        "claude",
        Vec::new(),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some("claude --version".to_owned()),
        "{agentRunDir}",
        Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()),
    )
    .unwrap()
}

/// A profile WITHOUT a `SessionStrategy` (no resume support).
fn profile_no_session(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Plain CLI",
        "aider",
        Vec::new(),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        None,
        "{agentRunDir}",
        None,
    )
    .unwrap()
}

/// Builds a manifest entry for `agent` named `name` on profile `profile_id`.
fn entry(agent: AgentId, name: &str, profile_id: ProfileId) -> ManifestEntry {
    let a = Agent::new(
        agent,
        name,
        format!("agents/{name}.md"),
        profile_id,
        AgentOrigin::Scratch,
        false,
    )
    .unwrap();
    ManifestEntry::from_agent(&a)
}

/// A leaf cell hosting `agent`, optionally carrying a conversation + run flag.
fn agent_leaf(
    node: NodeId,
    agent: Option<AgentId>,
    conversation_id: Option<&str>,
    agent_was_running: bool,
) -> LeafCell {
    LeafCell {
        id: node,
        session: None,
        agent,
        conversation_id: conversation_id.map(str::to_owned),
        engine_session_id: None,
        agent_was_running,
    }
}

/// Seeds a valid `layouts.json` with one active layout holding `tree`.
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
    let doc = serde_json::json!({
        "version": 1,
        "activeId": id.to_string(),
        "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
    });
    fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
}

/// Wires the use case over the three fakes.
fn make_use_case(
    store: &FakeStore,
    fs: &FakeFs,
    contexts: &FakeContexts,
    profiles: &FakeProfiles,
) -> ListResumableAgents {
    ListResumableAgents::new(
        Arc::new(store.clone()) as Arc<dyn ProjectStore>,
        Arc::new(fs.clone()) as Arc<dyn FileSystem>,
        Arc::new(contexts.clone()) as Arc<dyn AgentContextStore>,
        Arc::new(profiles.clone()) as Arc<dyn ProfileStore>,
    )
}

async fn run(uc: &ListResumableAgents) -> Vec<application::ResumableAgent> {
    uc.execute(ListResumableAgentsInput { project: project() })
        .await
        .expect("best-effort: never errors")
        .resumable
}

// ---------------------------------------------------------------------------
// 1. Correct inventory: both resumable cells appear with the right fields
// ---------------------------------------------------------------------------

/// A layout with two agent cells — one `was_running=true` (no conv), one with a
/// `conversation_id` (not running) — yields both entries, each with the right
/// `agent_id`, `name` (from manifest), `node_id`, `conversation_id`, `was_running`.
#[tokio::test]
async fn inventory_lists_both_resumable_cells() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let running_leaf = nid(10);
    let conv_leaf = nid(11);
    let running_agent = aid(100);
    let conv_agent = aid(101);

    let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
        id: nid(1),
        direction: Direction::Row,
        children: vec![
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(running_leaf, Some(running_agent), None, true)),
                weight: 1.0,
            },
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(
                    conv_leaf,
                    Some(conv_agent),
                    Some("conv-xyz"),
                    false,
                )),
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&fs, lid(1), &tree);

    let contexts = FakeContexts::new(vec![
        entry(running_agent, "Backend", pid(1)),
        entry(conv_agent, "Tester", pid(1)),
    ]);
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(out.len(), 2, "both resumable cells listed: {out:?}");

    let running = out
        .iter()
        .find(|r| r.agent_id == running_agent)
        .expect("running agent present");
    assert_eq!(running.name, "Backend");
    assert_eq!(running.node_id, running_leaf);
    assert_eq!(running.conversation_id, None);
    assert!(running.was_running);
    assert!(running.resume_supported, "profile has a SessionStrategy");

    let conv = out
        .iter()
        .find(|r| r.agent_id == conv_agent)
        .expect("conversation agent present");
    assert_eq!(conv.name, "Tester");
    assert_eq!(conv.node_id, conv_leaf);
    assert_eq!(conv.conversation_id, Some("conv-xyz".to_owned()));
    assert!(!conv.was_running);
    assert!(conv.resume_supported);
}

// ---------------------------------------------------------------------------
// 2. Filter: a never-launched agent cell is excluded
// ---------------------------------------------------------------------------

/// An agent cell with neither `was_running` nor `conversation_id` is filtered out
/// (it launches normally on click, no popup), while a sibling resumable cell stays.
#[tokio::test]
async fn never_launched_cell_is_excluded() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let fresh_leaf = nid(10);
    let resumable_leaf = nid(11);
    let fresh_agent = aid(100);
    let resumable_agent = aid(101);

    let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
        id: nid(1),
        direction: Direction::Row,
        children: vec![
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(fresh_leaf, Some(fresh_agent), None, false)),
                weight: 1.0,
            },
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(
                    resumable_leaf,
                    Some(resumable_agent),
                    Some("c1"),
                    false,
                )),
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&fs, lid(1), &tree);

    let contexts = FakeContexts::new(vec![
        entry(fresh_agent, "Fresh", pid(1)),
        entry(resumable_agent, "Resumable", pid(1)),
    ]);
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(out.len(), 1, "only the resumable cell: {out:?}");
    assert_eq!(out[0].agent_id, resumable_agent);
    assert!(
        !out.iter().any(|r| r.agent_id == fresh_agent),
        "never-launched agent must not appear"
    );
}

// ---------------------------------------------------------------------------
// 3. resume_supported reflects the agent's profile
// ---------------------------------------------------------------------------

/// `resume_supported` is `true` for an agent on a profile with a SessionStrategy
/// and `false` for one without — but the latter is STILL listed (it carries a
/// `conversation_id`).
#[tokio::test]
async fn resume_supported_follows_profile() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let supported_leaf = nid(10);
    let plain_leaf = nid(11);
    let supported_agent = aid(100);
    let plain_agent = aid(101);

    let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
        id: nid(1),
        direction: Direction::Row,
        children: vec![
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(
                    supported_leaf,
                    Some(supported_agent),
                    Some("c1"),
                    true,
                )),
                weight: 1.0,
            },
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)),
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&fs, lid(1), &tree);

    let contexts = FakeContexts::new(vec![
        entry(supported_agent, "Resumable", pid(1)),
        entry(plain_agent, "Plain", pid(2)),
    ]);
    let profiles = FakeProfiles::new(vec![
        profile_with_session(pid(1)),
        profile_no_session(pid(2)),
    ]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(out.len(), 2, "both still listed: {out:?}");

    let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap();
    assert!(supported.resume_supported, "profile pid(1) has a session");

    let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap();
    assert!(
        !plain.resume_supported,
        "profile pid(2) has no session ⇒ resume_supported=false"
    );
    assert_eq!(
        plain.conversation_id,
        Some("c2".to_owned()),
        "still listed by its conversation_id"
    );
}

// ---------------------------------------------------------------------------
// 4. Best-effort / never an error
// ---------------------------------------------------------------------------

/// No `layouts.json` at all ⇒ empty inventory, `Ok` (best-effort, no panic).
#[tokio::test]
async fn missing_layouts_yields_empty_ok() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default(); // nothing seeded

    let contexts = FakeContexts::new(vec![entry(aid(100), "Backend", pid(1))]);
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert!(out.is_empty(), "no layouts ⇒ empty: {out:?}");
}

/// Failing manifest load ⇒ empty inventory, `Ok` (cannot resolve names/profiles).
#[tokio::test]
async fn failing_manifest_yields_empty_ok() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    seed_layouts(
        &fs,
        lid(1),
        &LayoutTree::single(agent_leaf(nid(10), Some(aid(100)), Some("c1"), true)),
    );

    let contexts = FakeContexts::failing();
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert!(out.is_empty(), "manifest unreadable ⇒ empty: {out:?}");
}

/// An agent present in a layout but ABSENT from the manifest is ignored — no
/// orphan entry — while a sibling agent that IS in the manifest is still listed.
#[tokio::test]
async fn agent_absent_from_manifest_is_ignored() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let orphan_leaf = nid(10);
    let known_leaf = nid(11);
    let orphan_agent = aid(100); // NOT in manifest
    let known_agent = aid(101); // in manifest

    let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
        id: nid(1),
        direction: Direction::Row,
        children: vec![
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(
                    orphan_leaf,
                    Some(orphan_agent),
                    Some("c1"),
                    true,
                )),
                weight: 1.0,
            },
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(known_leaf, Some(known_agent), Some("c2"), true)),
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&fs, lid(1), &tree);

    let contexts = FakeContexts::new(vec![entry(known_agent, "Known", pid(1))]);
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(out.len(), 1, "orphan ignored, known listed: {out:?}");
    assert_eq!(out[0].agent_id, known_agent);
    assert!(
        !out.iter().any(|r| r.agent_id == orphan_agent),
        "orphan agent (not in manifest) must not appear"
    );
}

/// A failing `ProfileStore::list` ⇒ agents are STILL listed (resumable by their
/// `conversation_id`/`was_running`), but each with `resume_supported = false`.
#[tokio::test]
async fn failing_profile_store_lists_agents_without_resume_support() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let leaf = nid(10);
    let agent = aid(100);
    seed_layouts(
        &fs,
        lid(1),
        &LayoutTree::single(agent_leaf(leaf, Some(agent), Some("c1"), true)),
    );

    // The agent is on a profile that DOES support resume, but the profile store
    // is unavailable — so resume_supported must degrade to false.
    let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]);
    let profiles = FakeProfiles::failing();
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(
        out.len(),
        1,
        "agent still listed despite profile failure: {out:?}"
    );
    assert_eq!(out[0].agent_id, agent);
    assert!(
        !out[0].resume_supported,
        "profiles unavailable ⇒ resume_supported=false"
    );
}

// ---------------------------------------------------------------------------
// 5. Non-agent / split / grid cells produce no spurious entries
// ---------------------------------------------------------------------------

/// A mixed tree with a plain (non-agent) leaf nested in splits and grids yields
/// no parasitic entries: only the genuine resumable agent cell is reported.
#[tokio::test]
async fn non_agent_split_and_grid_cells_are_ignored() {
    let store = FakeStore::default();
    store.save_project(&project()).await.unwrap();
    let fs = FakeFs::default();

    let plain_leaf = nid(10);
    let grid_plain_leaf = nid(12);
    let agent_cell = nid(13);
    let agent = aid(100);

    // A grid holding a plain leaf and the resumable agent leaf.
    let grid = LayoutNode::Grid(GridContainer {
        id: nid(2),
        col_weights: vec![1.0, 1.0],
        row_weights: vec![1.0],
        cells: vec![
            GridCell {
                node: LayoutNode::Leaf(agent_leaf(grid_plain_leaf, None, None, false)),
                row: 0,
                col: 0,
                row_span: 1,
                col_span: 1,
            },
            GridCell {
                node: LayoutNode::Leaf(agent_leaf(agent_cell, Some(agent), Some("c1"), true)),
                row: 0,
                col: 1,
                row_span: 1,
                col_span: 1,
            },
        ],
    });

    // A split holding a plain leaf and the grid above.
    let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
        id: nid(1),
        direction: Direction::Column,
        children: vec![
            WeightedChild {
                node: LayoutNode::Leaf(agent_leaf(plain_leaf, None, None, false)),
                weight: 1.0,
            },
            WeightedChild {
                node: grid,
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&fs, lid(1), &tree);

    let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]);
    let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
    let uc = make_use_case(&store, &fs, &contexts, &profiles);

    let out = run(&uc).await;
    assert_eq!(out.len(), 1, "only the agent grid-cell counts: {out:?}");
    assert_eq!(out[0].agent_id, agent);
    assert_eq!(out[0].node_id, agent_cell);
    assert!(out[0].resume_supported);
}
