//! L4 + #4 tests for the layout use cases (`LoadLayout`, `MutateLayout`) and the
//! named-layout management (`ListLayouts`, `CreateLayout`, `RenameLayout`,
//! `DeleteLayout`, `SetActiveLayout`).
//!
//! Every port is faked in-memory so the use cases run without any real I/O.
//! Layouts now persist to `.ideai/layouts.json` (a collection); a legacy
//! `.ideai/layout.json` is migrated transparently.

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

use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::layout::Workspace;
use domain::ports::{
    DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, PluginManifestBytes,
    PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore,
    RemotePath, StoreError,
};
use domain::{
    AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
    PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
    PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef,
    RemovalOutcome, SessionId, StagedPluginPackage,
};
use uuid::Uuid;

use application::plugin::JsonPluginManifestValidator;
use application::{
    CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
    ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
    PluginLayoutOrigin, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};

// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------

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

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

impl FakeFs {
    fn read_file(&self, path: &str) -> Option<Vec<u8>> {
        self.0.lock().unwrap().files.get(path).cloned()
    }
    fn has_dir(&self, path: &str) -> bool {
        self.0.lock().unwrap().dirs.contains(path)
    }
    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(())
    }
}

#[derive(Default)]
struct FakeStoreInner {
    projects: Vec<Project>,
}

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

#[async_trait]
impl ProjectStore for FakeStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        Ok(self.0.lock().unwrap().projects.clone())
    }
    async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
        self.0
            .lock()
            .unwrap()
            .projects
            .iter()
            .find(|p| p.id == id)
            .cloned()
            .ok_or(StoreError::NotFound)
    }
    async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
        self.0.lock().unwrap().projects.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())
    }
}

#[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(start: u128) -> Self {
        Self(Mutex::new(start))
    }
}
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
    }
}

fn plugin_manifest() -> Vec<u8> {
    br#"{
      "ideaPluginManifestVersion": 1,
      "id": "dev.idea.android-plugin",
      "displayName": "Android",
      "version": "1.0.0",
      "engines": {"idea": ">=0.1.0 <1.0.0"},
      "main": "dist/index.js",
      "trustLevel": "full",
      "capabilities": ["ui"],
      "contributes": {
        "layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
      }
    }"#.to_vec()
}

#[derive(Clone)]
struct FakePluginPackages {
    manifest: Arc<Mutex<Vec<u8>>>,
}

impl FakePluginPackages {
    fn new(manifest: Vec<u8>) -> Self {
        Self {
            manifest: Arc::new(Mutex::new(manifest)),
        }
    }
}

#[async_trait]
impl PluginPackageStore for FakePluginPackages {
    async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
        Ok(Vec::new())
    }

    async fn read_manifest(
        &self,
        _package: &PluginPackageRef,
    ) -> Result<PluginManifestBytes, PluginStoreError> {
        Ok(PluginManifestBytes {
            bytes: self.manifest.lock().unwrap().clone(),
        })
    }

    async fn install_from_archive(
        &self,
        _archive: &LocalPath,
    ) -> Result<StagedPluginPackage, PluginStoreError> {
        Err(PluginStoreError::Invalid("unused".to_owned()))
    }

    async fn install_from_directory(
        &self,
        _dir: &LocalPath,
    ) -> Result<StagedPluginPackage, PluginStoreError> {
        Err(PluginStoreError::Invalid("unused".to_owned()))
    }

    async fn commit_install(
        &self,
        staged: StagedPluginPackage,
        plugin_id: &PluginId,
    ) -> Result<PluginPackageRef, PluginStoreError> {
        Ok(PluginPackageRef {
            plugin_id: Some(plugin_id.clone()),
            root: staged.root,
        })
    }

    async fn remove_package(
        &self,
        _plugin_id: &PluginId,
    ) -> Result<RemovalOutcome, PluginStoreError> {
        Ok(RemovalOutcome::NotFound)
    }

    fn bundle_url(
        &self,
        plugin_id: &PluginId,
        entry: &RelativePath,
        hash: &ContentHash,
    ) -> Result<PluginBundleUrl, PluginStoreError> {
        Ok(PluginBundleUrl::new(format!(
            "idea-plugin://{}/current/{}/{}",
            plugin_id.as_str(),
            hash.as_str(),
            entry.as_str()
        )))
    }
}

#[derive(Clone)]
struct FakePluginRegistry {
    registry: Arc<Mutex<PluginRegistry>>,
}

impl FakePluginRegistry {
    fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
        Self {
            registry: Arc::new(Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: PluginId::new("dev.idea.android-plugin").unwrap(),
                    lifecycle_state,
                    source: PluginInstallSource::Directory {
                        path_label: "/plugin".to_owned(),
                    },
                    content_hash: ContentHash::new("abc123").unwrap(),
                    restart_required: false,
                    error: None,
                }],
            })),
        }
    }
}

#[async_trait]
impl PluginRegistryStore for FakePluginRegistry {
    async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
        Ok(self.registry.lock().unwrap().clone())
    }

    async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
        *self.registry.lock().unwrap() = registry.clone();
        Ok(())
    }
}

fn create_layout(
    store: FakeStore,
    fs: FakeFs,
    ids: SeqIds,
    bus: SpyBus,
    registry: FakePluginRegistry,
) -> CreateLayout {
    CreateLayout::new(
        Arc::new(store),
        Arc::new(fs),
        Arc::new(ids),
        Arc::new(bus),
        Arc::new(FakePluginPackages::new(plugin_manifest())),
        Arc::new(registry),
        Arc::new(JsonPluginManifestValidator::new("0.3.0")),
    )
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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

fn pid(n: u128) -> ProjectId {
    ProjectId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
    NodeId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
    SessionId::from_uuid(Uuid::from_u128(n))
}
fn lid(n: u128) -> LayoutId {
    LayoutId::from_uuid(Uuid::from_u128(n))
}

async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
    let project = Project::new(
        id,
        "Demo",
        ProjectPath::new(ROOT).unwrap(),
        RemoteRef::Local,
        0,
    )
    .unwrap();
    store.save_project(&project).await.unwrap();
    id
}

fn single_leaf(node_id: NodeId) -> LayoutTree {
    LayoutTree::single(LeafCell {
        id: node_id,
        session: None,
        agent: None,
        conversation_id: None,
        engine_session_id: None,
        agent_was_running: false,
    })
}

/// Seeds a valid `layouts.json` with a single 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());
}

fn doc_json(fs: &FakeFs) -> serde_json::Value {
    serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json written")).unwrap()
}

/// The JSON of the active layout's tree.
fn active_tree_json(fs: &FakeFs) -> serde_json::Value {
    let doc = doc_json(fs);
    let active = doc["activeId"].clone();
    doc["layouts"]
        .as_array()
        .unwrap()
        .iter()
        .find(|l| l["id"] == active)
        .expect("active layout present")["tree"]
        .clone()
}

fn read_active_tree(fs: &FakeFs) -> LayoutTree {
    serde_json::from_value(active_tree_json(fs)).expect("active tree parseable")
}

fn root_leaf_id(tree: &LayoutTree) -> NodeId {
    match &tree.root {
        LayoutNode::Leaf(l) => l.id,
        _ => panic!("expected a single-leaf root"),
    }
}

// ---------------------------------------------------------------------------
// LoadLayout
// ---------------------------------------------------------------------------

#[tokio::test]
async fn load_returns_persisted_active_layout() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = register_project(&store, pid(1)).await;
    seed_layouts(&fs, lid(1), &single_leaf(nid(42)));

    let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
    let out = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: None,
        })
        .await
        .expect("load succeeds");

    assert_eq!(out.layout_id, lid(1));
    assert_eq!(root_leaf_id(&out.layout), nid(42));
}

#[tokio::test]
async fn load_migrates_a_legacy_layout_json() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = register_project(&store, pid(2)).await;
    // Only the legacy single-layout file exists.
    fs.put(
        LEGACY_PATH,
        &serde_json::to_vec(&single_leaf(nid(7))).unwrap(),
    );

    let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
    let out = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: None,
        })
        .await
        .expect("legacy layout migrates");

    assert_eq!(root_leaf_id(&out.layout), nid(7), "legacy tree preserved");
    // A layouts.json was written with that tree as the active layout.
    assert_eq!(root_leaf_id(&read_active_tree(&fs)), nid(7));
}

#[tokio::test]
async fn load_defaults_to_single_empty_leaf_when_absent() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = register_project(&store, pid(3)).await;

    let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
    let out = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: None,
        })
        .await
        .expect("absent layout does not fail");

    match &out.layout.root {
        LayoutNode::Leaf(l) => assert!(l.session.is_none()),
        _ => panic!("expected a single default leaf"),
    }
    // Default was written through; two loads are deterministic.
    assert_eq!(
        root_leaf_id(&read_active_tree(&fs)),
        root_leaf_id(&out.layout)
    );
    assert!(fs.has_dir("/home/me/proj/.ideai"));
}

#[tokio::test]
async fn load_tolerates_corrupt_json_with_default() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = register_project(&store, pid(4)).await;
    fs.put(LAYOUTS_PATH, b"{ not json ]");

    let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
    let out = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: None,
        })
        .await
        .expect("corrupt JSON falls back to default");

    assert!(matches!(out.layout.root, LayoutNode::Leaf(_)));
    assert!(out.layout.validate().is_ok());
}

#[tokio::test]
async fn load_unknown_layout_id_self_heals_to_active() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = register_project(&store, pid(5)).await;
    seed_layouts(&fs, lid(1), &single_leaf(nid(1)));

    // A stale requested id (e.g. left over after git overwrote layouts.json)
    // must NOT hard-error and freeze the workspace; it degrades silently to the
    // valid active layout (invariant I3), whose id is authoritative (I4).
    let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
    let out = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: Some(lid(999)),
        })
        .await
        .expect("stale layout id must self-heal, not error");
    assert_eq!(out.layout_id, lid(1));
}

#[tokio::test]
async fn load_unknown_project_is_not_found() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
    let err = load
        .execute(LoadLayoutInput {
            project_id: pid(999),
            layout_id: None,
        })
        .await
        .expect_err("unknown project rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

// ---------------------------------------------------------------------------
// MutateLayout
// ---------------------------------------------------------------------------

struct MutEnv {
    fs: FakeFs,
    bus: SpyBus,
    mutate: MutateLayout,
    project_id: ProjectId,
}

async fn mut_env(project_id: ProjectId) -> MutEnv {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let bus = SpyBus::default();
    register_project(&store, project_id).await;
    seed_layouts(&fs, lid(1), &single_leaf(nid(1)));

    let mutate = MutateLayout::new(
        Arc::new(store.clone()),
        Arc::new(fs.clone()),
        Arc::new(bus.clone()),
    );
    MutEnv {
        fs,
        bus,
        mutate,
        project_id,
    }
}

#[tokio::test]
async fn mutate_split_persists_camelcase_layout_and_announces() {
    let env = mut_env(pid(10)).await;
    let out = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::Split {
                target: nid(1),
                direction: Direction::Row,
                new_leaf: nid(2),
                container: nid(9),
            },
        })
        .await
        .expect("split succeeds");

    match &out.layout.root {
        LayoutNode::Split(s) => assert_eq!(s.children.len(), 2),
        _ => panic!("expected a split root"),
    }

    assert!(env.fs.has_dir("/home/me/proj/.ideai"));
    let tree = active_tree_json(&env.fs);
    assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
    assert_eq!(tree["root"]["node"]["direction"], "row");
    assert_eq!(
        tree["root"]["node"]["children"].as_array().unwrap().len(),
        2
    );

    assert_eq!(
        env.bus.events(),
        vec![DomainEvent::LayoutChanged {
            project_id: env.project_id
        }]
    );
}

#[tokio::test]
async fn mutate_resize_writes_new_weights() {
    let env = mut_env(pid(11)).await;
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::Split {
                target: nid(1),
                direction: Direction::Row,
                new_leaf: nid(2),
                container: nid(9),
            },
        })
        .await
        .unwrap();
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::Resize {
                container: nid(9),
                weights: vec![3.0, 1.0],
            },
        })
        .await
        .expect("resize succeeds");

    let tree = active_tree_json(&env.fs);
    let children = tree["root"]["node"]["children"].as_array().unwrap();
    assert_eq!(children[0]["weight"], 3.0);
    assert_eq!(children[1]["weight"], 1.0);
}

#[tokio::test]
async fn mutate_set_session_attaches_and_clears() {
    let env = mut_env(pid(12)).await;
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetSession {
                target: nid(1),
                session: Some(sid(77)),
            },
        })
        .await
        .expect("attach");
    assert_eq!(
        active_tree_json(&env.fs)["root"]["node"]["session"],
        sid(77).to_string()
    );

    let out = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetSession {
                target: nid(1),
                session: None,
            },
        })
        .await
        .expect("detach");
    match &out.layout.root {
        LayoutNode::Leaf(l) => assert!(l.session.is_none()),
        _ => panic!("expected leaf root"),
    }
    assert!(active_tree_json(&env.fs)["root"]["node"]
        .get("session")
        .is_none());
}

#[tokio::test]
async fn mutate_attach_session_moves_session_between_cells() {
    let env = mut_env(pid(13)).await;
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::Split {
                target: nid(1),
                direction: Direction::Row,
                new_leaf: nid(2),
                container: nid(9),
            },
        })
        .await
        .expect("split");
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetSession {
                target: nid(1),
                session: Some(sid(77)),
            },
        })
        .await
        .expect("set initial session");

    let out = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::AttachSession {
                target: nid(2),
                session: sid(77),
            },
        })
        .await
        .expect("attach existing session");

    match &out.layout.root {
        LayoutNode::Split(split) => {
            match &split.children[0].node {
                LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()),
                _ => panic!("expected first leaf"),
            }
            match &split.children[1].node {
                LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))),
                _ => panic!("expected second leaf"),
            }
        }
        _ => panic!("expected split root"),
    }
}

#[tokio::test]
async fn mutate_invalid_op_errors_and_does_not_persist() {
    let env = mut_env(pid(14)).await;
    // Force the layouts.json to exist first (a clean load) so we have a baseline.
    let before = doc_json(&env.fs);

    let err = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetSession {
                target: nid(404),
                session: Some(sid(1)),
            },
        })
        .await
        .expect_err("set_session on unknown node fails");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");

    assert_eq!(before, doc_json(&env.fs), "failed op must not overwrite");
    assert!(env.bus.events().is_empty(), "no event on failed mutation");
}

#[tokio::test]
async fn load_then_set_session_on_returned_id_succeeds() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let bus = SpyBus::default();
    let id = register_project(&store, pid(22)).await;

    let load = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()));
    let mutate = MutateLayout::new(
        Arc::new(store.clone()),
        Arc::new(fs.clone()),
        Arc::new(bus.clone()),
    );

    let loaded = load
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: None,
        })
        .await
        .expect("load");
    let leaf = root_leaf_id(&loaded.layout);

    mutate
        .execute(MutateLayoutInput {
            project_id: id,
            layout_id: None,
            operation: LayoutOperation::SetSession {
                target: leaf,
                session: Some(sid(7)),
            },
        })
        .await
        .expect("set_session on the just-loaded leaf id must succeed");

    match &read_active_tree(&fs).root {
        LayoutNode::Leaf(l) => assert_eq!(l.session, Some(sid(7))),
        _ => panic!("expected persisted leaf root"),
    }
}

// ---------------------------------------------------------------------------
// SetCellAgent (#3 — per-cell agent)
// ---------------------------------------------------------------------------

fn aid(n: u128) -> AgentId {
    AgentId::from_uuid(Uuid::from_u128(n))
}

#[tokio::test]
async fn mutate_set_cell_agent_persists_agent_on_leaf() {
    let env = mut_env(pid(50)).await;
    // Attach an agent to the single root leaf (nid(1)).
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellAgent {
                target: nid(1),
                agent: Some(aid(0xAA)),
            },
        })
        .await
        .expect("set_cell_agent attaches");

    // Verify persisted JSON has the agent field.
    let tree_json = active_tree_json(&env.fs);
    assert_eq!(
        tree_json["root"]["node"]["agent"],
        aid(0xAA).to_string(),
        "agent must be persisted on the leaf"
    );

    // Now clear it.
    let out = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellAgent {
                target: nid(1),
                agent: None,
            },
        })
        .await
        .expect("set_cell_agent clears");

    match &out.layout.root {
        LayoutNode::Leaf(l) => assert_eq!(l.agent, None, "agent must be cleared"),
        _ => panic!("expected leaf root"),
    }
    assert!(
        active_tree_json(&env.fs)["root"]["node"]
            .get("agent")
            .is_none(),
        "cleared agent must not be serialised"
    );
}

#[tokio::test]
async fn mutate_set_cell_agent_missing_leaf_is_not_found() {
    let env = mut_env(pid(51)).await;
    let err = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellAgent {
                target: nid(404),
                agent: Some(aid(1)),
            },
        })
        .await
        .expect_err("unknown node rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

// ---------------------------------------------------------------------------
// SetCellConversation (T4b — persist the assigned CLI conversation id)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn mutate_set_cell_conversation_persists_id_on_leaf() {
    let env = mut_env(pid(52)).await;
    // Record a conversation id on the single root leaf (nid(1)).
    env.mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellConversation {
                target: nid(1),
                conversation_id: Some("conv-42".to_owned()),
            },
        })
        .await
        .expect("set_cell_conversation records the id");

    // The id must survive in the persisted JSON, so the next open resumes.
    let tree_json = active_tree_json(&env.fs);
    assert_eq!(
        tree_json["root"]["node"]["conversationId"], "conv-42",
        "conversation id must be persisted on the leaf"
    );

    // Now clear it.
    let out = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellConversation {
                target: nid(1),
                conversation_id: None,
            },
        })
        .await
        .expect("set_cell_conversation clears the id");

    match &out.layout.root {
        LayoutNode::Leaf(l) => assert_eq!(l.conversation_id, None, "id must be cleared"),
        _ => panic!("expected leaf root"),
    }
    assert!(
        active_tree_json(&env.fs)["root"]["node"]
            .get("conversationId")
            .is_none(),
        "cleared id must not be serialised"
    );
}

#[tokio::test]
async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
    let env = mut_env(pid(53)).await;
    let err = env
        .mutate
        .execute(MutateLayoutInput {
            project_id: env.project_id,
            layout_id: None,
            operation: LayoutOperation::SetCellConversation {
                target: nid(404),
                conversation_id: Some("x".to_owned()),
            },
        })
        .await
        .expect_err("unknown node rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

// ---------------------------------------------------------------------------
// Named-layout management (#4)
// ---------------------------------------------------------------------------

/// Builds a project + fs + bus with a seeded single "Default" layout (id 1).
async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let bus = SpyBus::default();
    register_project(&store, project_id).await;
    seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
    (store, fs, bus)
}

#[tokio::test]
async fn create_layout_appends_and_activates_it() {
    let (store, fs, bus) = mgmt_env(pid(30)).await;
    let create = create_layout(
        store.clone(),
        fs.clone(),
        SeqIds::new(0xABC),
        bus.clone(),
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    );
    let out = create
        .execute(CreateLayoutInput {
            project_id: pid(30),
            name: "Backend".to_owned(),
            kind: LayoutKind::Terminal,
        })
        .await
        .unwrap();

    let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
        .execute(ListLayoutsInput {
            project_id: pid(30),
        })
        .await
        .unwrap();
    assert_eq!(list.layouts.len(), 2, "Default + Backend");
    assert_eq!(list.active_id, out.layout_id, "new layout is active");
    assert!(list.layouts.iter().any(|l| l.name == "Backend"));
}

#[tokio::test]
async fn create_layout_rejects_empty_name() {
    let (store, fs, bus) = mgmt_env(pid(31)).await;
    let err = create_layout(
        store,
        fs,
        SeqIds::new(1),
        bus,
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    )
    .execute(CreateLayoutInput {
        project_id: pid(31),
        name: "   ".to_owned(),
        kind: LayoutKind::Terminal,
    })
    .await
    .unwrap_err();
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn create_layout_plugin_validates_active_runtime_contribution_and_persists_root() {
    let (store, fs, bus) = mgmt_env(pid(131)).await;
    let create = create_layout(
        store.clone(),
        fs.clone(),
        SeqIds::new(0x141),
        bus,
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    );
    let out = create
        .execute(CreateLayoutInput {
            project_id: pid(131),
            name: "Android Health".to_owned(),
            kind: LayoutKind::Plugin {
                plugin_origin: PluginLayoutOrigin {
                    plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
                    layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
                },
                state: serde_json::json!({ "deviceId": "pixel-8" }),
            },
        })
        .await
        .unwrap();

    let list = ListLayouts::new(Arc::new(store), Arc::new(fs.clone()))
        .execute(ListLayoutsInput {
            project_id: pid(131),
        })
        .await
        .unwrap();
    assert_eq!(list.active_id, out.layout_id);
    assert_eq!(
        list.layouts[1].kind,
        LayoutKind::Plugin {
            plugin_origin: PluginLayoutOrigin {
                plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
                layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
            },
            state: serde_json::json!({ "deviceId": "pixel-8" }),
        }
    );

    let tree_json = active_tree_json(&fs);
    assert_eq!(tree_json["root"]["type"], "customPluginLayout");
    assert_eq!(
        tree_json["root"]["node"]["pluginId"],
        "dev.idea.android-plugin"
    );
    assert_eq!(
        tree_json["root"]["node"]["layoutType"],
        "idea-android.health"
    );
    assert_eq!(tree_json["root"]["node"]["state"]["deviceId"], "pixel-8");
}

#[tokio::test]
async fn create_layout_plugin_rejects_inactive_runtime_contribution() {
    let (store, fs, bus) = mgmt_env(pid(132)).await;
    let err = create_layout(
        store,
        fs,
        SeqIds::new(0x142),
        bus,
        FakePluginRegistry::with_state(PluginLifecycleState::Disabled),
    )
    .execute(CreateLayoutInput {
        project_id: pid(132),
        name: "Android Health".to_owned(),
        kind: LayoutKind::Plugin {
            plugin_origin: PluginLayoutOrigin {
                plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
                layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
            },
            state: serde_json::json!({}),
        },
    })
    .await
    .unwrap_err();
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn mutate_set_plugin_layout_state_persists_opaque_state() {
    let (store, fs, bus) = mgmt_env(pid(133)).await;
    let create = create_layout(
        store.clone(),
        fs.clone(),
        SeqIds::new(0x143),
        bus.clone(),
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    );
    create
        .execute(CreateLayoutInput {
            project_id: pid(133),
            name: "Android Health".to_owned(),
            kind: LayoutKind::Plugin {
                plugin_origin: PluginLayoutOrigin {
                    plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
                    layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
                },
                state: serde_json::json!({ "deviceId": "pixel-8" }),
            },
        })
        .await
        .unwrap();

    let loaded = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()))
        .execute(LoadLayoutInput {
            project_id: pid(133),
            layout_id: None,
        })
        .await
        .unwrap();
    let target = match loaded.layout.root {
        LayoutNode::CustomPluginLayout(cell) => cell.id,
        other => panic!("expected custom plugin layout root, got {other:?}"),
    };

    let out = MutateLayout::new(Arc::new(store), Arc::new(fs.clone()), Arc::new(bus))
        .execute(MutateLayoutInput {
            project_id: pid(133),
            layout_id: None,
            operation: LayoutOperation::SetPluginLayoutState {
                target,
                state: serde_json::json!({ "deviceId": "pixel-8", "healthy": true }),
            },
        })
        .await
        .unwrap();

    match out.layout.root {
        LayoutNode::CustomPluginLayout(cell) => {
            assert_eq!(cell.state["healthy"], true);
        }
        other => panic!("expected custom plugin layout root, got {other:?}"),
    }
    assert_eq!(
        active_tree_json(&fs)["root"]["node"]["state"]["healthy"],
        true
    );
}

#[tokio::test]
async fn rename_layout_changes_the_name() {
    let (store, fs, bus) = mgmt_env(pid(32)).await;
    RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
        .execute(RenameLayoutInput {
            project_id: pid(32),
            layout_id: lid(1),
            name: "Main".to_owned(),
        })
        .await
        .unwrap();

    let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
        .execute(ListLayoutsInput {
            project_id: pid(32),
        })
        .await
        .unwrap();
    assert_eq!(list.layouts[0].name, "Main");
}

#[tokio::test]
async fn delete_layout_rejects_the_last_one() {
    let (store, fs, bus) = mgmt_env(pid(33)).await;
    let err = DeleteLayout::new(Arc::new(store), Arc::new(fs), Arc::new(bus))
        .execute(DeleteLayoutInput {
            project_id: pid(33),
            layout_id: lid(1),
        })
        .await
        .unwrap_err();
    assert_eq!(err.code(), "INVALID", "cannot delete the last layout");
}

#[tokio::test]
async fn delete_active_layout_reassigns_active() {
    let (store, fs, bus) = mgmt_env(pid(34)).await;
    // Add a second layout (becomes active), then delete it → active falls back.
    let created = create_layout(
        store.clone(),
        fs.clone(),
        SeqIds::new(0xD),
        bus.clone(),
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    )
    .execute(CreateLayoutInput {
        project_id: pid(34),
        name: "Second".to_owned(),
        kind: LayoutKind::Terminal,
    })
    .await
    .unwrap();

    let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
        .execute(DeleteLayoutInput {
            project_id: pid(34),
            layout_id: created.layout_id,
        })
        .await
        .unwrap();
    assert_eq!(
        out.active_id,
        lid(1),
        "active fell back to the Default layout"
    );

    let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
        .execute(ListLayoutsInput {
            project_id: pid(34),
        })
        .await
        .unwrap();
    assert_eq!(list.layouts.len(), 1);
}

#[tokio::test]
async fn set_active_layout_switches_and_load_follows() {
    let (store, fs, bus) = mgmt_env(pid(35)).await;
    let created = create_layout(
        store.clone(),
        fs.clone(),
        SeqIds::new(0xE),
        bus.clone(),
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    )
    .execute(CreateLayoutInput {
        project_id: pid(35),
        name: "Second".to_owned(),
        kind: LayoutKind::Terminal,
    })
    .await
    .unwrap();

    // Switch back to the Default layout.
    let set = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
        .execute(SetActiveLayoutInput {
            project_id: pid(35),
            layout_id: lid(1),
        })
        .await
        .unwrap();
    // Output echoes the actually-activated id (I4).
    assert_eq!(set.active_id, lid(1));

    let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
        .execute(LoadLayoutInput {
            project_id: pid(35),
            layout_id: None,
        })
        .await
        .unwrap();
    assert_eq!(loaded.layout_id, lid(1));
    assert_ne!(loaded.layout_id, created.layout_id);
}

#[tokio::test]
async fn set_active_layout_stale_id_self_heals_to_current_active() {
    // Seeded env: a single "Default" layout, active = lid(1).
    let (store, fs, bus) = mgmt_env(pid(36)).await;

    // Targeting a stale id (e.g. an activeId left over after git overwrote
    // layouts.json) must NOT error and must NOT freeze the workspace (I3):
    // it degrades to the current active id and returns it (I4).
    let out = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
        .execute(SetActiveLayoutInput {
            project_id: pid(36),
            layout_id: lid(999),
        })
        .await
        .expect("stale set_active must self-heal, not error");
    assert_eq!(out.active_id, lid(1));

    // And a subsequent load of the current view resolves to that valid id.
    let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
        .execute(LoadLayoutInput {
            project_id: pid(36),
            layout_id: None,
        })
        .await
        .unwrap();
    assert_eq!(loaded.layout_id, lid(1));
}
