//! FileGuard-mediated context & memory use cases (cadrage C7).
//!
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
//! the right lease (shared read / exclusive write) for the requesting
//! [`ConversationParty`], then delegates to the existing store ports.
//!
//! ## Single-writer global context
//!
//! The global project context is single-writer: only the orchestrator
//! ([`ConversationParty::User`]) may write it directly. A project agent that
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
//! directly under a write-lease.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
//! scope here.

use std::sync::Arc;

use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{
    AgentContextStore, Clock, EventBus, FileSystem, FsError, MemoryStore, RemotePath,
};
use domain::{AgentId, DomainEvent, Project};
use sha2::{Digest, Sha256};

use crate::error::AppError;
use crate::project::meta::IDEAI_DIR;
use crate::project::project_context_path;

/// `.ideai/` subdirectory where a rejected global-context change is materialised.
const PROPOSALS_DIR: &str = ".ideai/proposals";

/// Joins a project root with a POSIX-relative segment (valid on every target).
fn join_root(project: &Project, rel: &str) -> RemotePath {
    let base = project.root.as_str().trim_end_matches(['/', '\\']);
    RemotePath::new(format!("{base}/{rel}"))
}

fn project_context_dir(project: &Project) -> RemotePath {
    join_root(project, IDEAI_DIR)
}

async fn read_project_context_bytes(
    fs: &Arc<dyn FileSystem>,
    project: &Project,
) -> Result<Vec<u8>, AppError> {
    match fs.read(&project_context_path(project)).await {
        Ok(bytes) => Ok(bytes),
        Err(FsError::NotFound(_)) => Ok(Vec::new()),
        Err(err) => Err(AppError::FileSystem(err.to_string())),
    }
}

pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex_encode(&hasher.finalize())
}

fn hex_encode(bytes: &[u8]) -> String {
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
    contexts: &Arc<dyn AgentContextStore>,
    project: &Project,
    name: &str,
) -> Result<AgentId, AppError> {
    let manifest = contexts.load_manifest(project).await?;
    manifest
        .entries
        .into_iter()
        .find(|e| e.name.eq_ignore_ascii_case(name))
        .map(|e| e.agent_id)
        .ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
}

/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
///
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
pub struct ReadContext {
    guard: Arc<dyn FileGuard>,
    contexts: Arc<dyn AgentContextStore>,
    fs: Arc<dyn FileSystem>,
}

/// Input for [`ReadContext`].
pub struct ReadContextInput {
    /// The project to read within.
    pub project: Project,
    /// Target agent display name; `None` ⇒ the global project context.
    pub target: Option<String>,
    /// The reading party (drives the read-lease holder identity).
    pub requester: ConversationParty,
}

/// Output of [`ReadContext`].
pub struct ReadContextOutput {
    /// The context Markdown.
    pub content: MarkdownDoc,
    /// sha256 hex digest of `content`, only for the global project context.
    pub version: Option<String>,
}

impl ReadContext {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(
        guard: Arc<dyn FileGuard>,
        contexts: Arc<dyn AgentContextStore>,
        fs: Arc<dyn FileSystem>,
    ) -> Self {
        Self {
            guard,
            contexts,
            fs,
        }
    }

    /// Reads the requested context, returning its Markdown body.
    ///
    /// # Errors
    /// [`AppError`] when the agent/context does not exist or the store/fs fails.
    pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
        let ReadContextInput {
            project,
            target,
            requester,
        } = input;
        match target {
            None => {
                // Global project context: shared read-lease, then read `.ideai/CONTEXT.md`.
                let _lease = self
                    .guard
                    .acquire_read(requester, GuardedResource::ProjectContext)
                    .await
                    .map_err(map_guard_err)?;
                let bytes = read_project_context_bytes(&self.fs, &project).await?;
                let version = hex_sha256(&bytes);
                let text =
                    String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
                Ok(ReadContextOutput {
                    content: MarkdownDoc::new(text),
                    version: Some(version),
                })
            }
            Some(name) => {
                let agent = resolve_agent(&self.contexts, &project, &name).await?;
                let _lease = self
                    .guard
                    .acquire_read(requester, GuardedResource::AgentContext(agent))
                    .await
                    .map_err(map_guard_err)?;
                Ok(ReadContextOutput {
                    content: self.contexts.read_context(&project, &agent).await?,
                    version: None,
                })
            }
        }
    }
}

/// Directly updates the global project context. This is the strict, fail-loud
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
pub struct UpdateProjectContext {
    guard: Arc<dyn FileGuard>,
    contexts: Arc<dyn AgentContextStore>,
    fs: Arc<dyn FileSystem>,
    events: Arc<dyn EventBus>,
    clock: Arc<dyn Clock>,
}

/// Input for [`UpdateProjectContext`].
pub struct UpdateProjectContextInput {
    /// The project to write within.
    pub project: Project,
    /// New global project context Markdown.
    pub content: String,
    /// Optional expected current version.
    pub if_match: Option<String>,
    /// The writing party.
    pub requester: ConversationParty,
}

/// Output of [`UpdateProjectContext`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectContextOutput {
    /// sha256 hex digest of the newly written content.
    pub new_version: String,
}

impl UpdateProjectContext {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(
        guard: Arc<dyn FileGuard>,
        contexts: Arc<dyn AgentContextStore>,
        fs: Arc<dyn FileSystem>,
        events: Arc<dyn EventBus>,
        clock: Arc<dyn Clock>,
    ) -> Self {
        Self {
            guard,
            contexts,
            fs,
            events,
            clock,
        }
    }

    /// Executes the direct global-context update.
    ///
    /// # Errors
    /// - [`AppError::Invalid`] when the requester is not allowed to write directly,
    /// - [`AppError::Conflict`] when `if_match` does not match current content,
    /// - [`AppError`] on store/fs failure.
    pub async fn execute(
        &self,
        input: UpdateProjectContextInput,
    ) -> Result<UpdateProjectContextOutput, AppError> {
        let UpdateProjectContextInput {
            project,
            content,
            if_match,
            requester,
        } = input;

        let manifest = self.contexts.load_manifest(&project).await?;
        let designation = manifest.orchestrator_designation();
        let resource = GuardedResource::ProjectContext;
        if !may_write_directly(requester, &resource, &designation) {
            return Err(map_guard_err(GuardError::Forbidden));
        }

        let _lease = self
            .guard
            .acquire_write(requester, resource)
            .await
            .map_err(map_guard_err)?;

        let path = project_context_path(&project);
        let current_bytes = read_project_context_bytes(&self.fs, &project).await?;
        let current_version = hex_sha256(&current_bytes);
        if let Some(expected) = if_match {
            if expected != current_version {
                return Err(AppError::Conflict(current_version));
            }
        }

        self.fs
            .create_dir_all(&project_context_dir(&project))
            .await?;
        self.fs.write(&path, content.as_bytes()).await?;
        let new_version = hex_sha256(content.as_bytes());
        self.events.publish(DomainEvent::ProjectContextUpdated {
            project_id: project.id,
            by: requester,
            at_ms: self.clock.now_millis(),
        });

        Ok(UpdateProjectContextOutput { new_version })
    }
}

/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
/// **global** project context by a non-orchestrator: the use case asks the domain
/// policy whether the requester may write directly; otherwise it materialises a
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
/// context.
pub struct ProposeContext {
    guard: Arc<dyn FileGuard>,
    contexts: Arc<dyn AgentContextStore>,
    fs: Arc<dyn FileSystem>,
    clock: Arc<dyn Clock>,
}

/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposeOutcome {
    /// The content was written directly (agent context, or global by the orchestrator).
    Written,
    /// A proposal was materialised at the given `.ideai/proposals/…` path (a
    /// non-orchestrator targeting the single-writer global context).
    Proposed {
        /// The proposal file's path.
        path: String,
    },
}

/// Input for [`ProposeContext`].
pub struct ProposeContextInput {
    /// The project to write within.
    pub project: Project,
    /// Target agent display name; `None` ⇒ the global project context.
    pub target: Option<String>,
    /// The proposed Markdown body.
    pub content: String,
    /// The proposing party.
    pub requester: ConversationParty,
}

impl ProposeContext {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(
        guard: Arc<dyn FileGuard>,
        contexts: Arc<dyn AgentContextStore>,
        fs: Arc<dyn FileSystem>,
        clock: Arc<dyn Clock>,
    ) -> Self {
        Self {
            guard,
            contexts,
            fs,
            clock,
        }
    }

    /// Applies the proposal: direct write under a write-lease, or a materialised
    /// proposal when the guard forbids a direct global-context write.
    ///
    /// # Errors
    /// [`AppError`] when the agent does not exist or the store/fs fails.
    pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
        let ProposeContextInput {
            project,
            target,
            content,
            requester,
        } = input;
        match target {
            Some(name) => {
                // Per-agent context: direct write under an exclusive write-lease.
                let agent = resolve_agent(&self.contexts, &project, &name).await?;
                let _lease = self
                    .guard
                    .acquire_write(requester, GuardedResource::AgentContext(agent))
                    .await
                    .map_err(map_guard_err)?;
                self.contexts
                    .write_context(&project, &agent, &MarkdownDoc::new(content))
                    .await?;
                Ok(ProposeOutcome::Written)
            }
            None => {
                // Global project context: single-writer. Authorization stays in the
                // domain policy; the guard only serialises the eventual direct write.
                let manifest = self.contexts.load_manifest(&project).await?;
                let designation = manifest.orchestrator_designation();
                let resource = GuardedResource::ProjectContext;
                if !may_write_directly(requester, &resource, &designation) {
                    let path = self.file_proposal(&project, requester, &content).await?;
                    return Ok(ProposeOutcome::Proposed { path });
                }
                let _lease = self
                    .guard
                    .acquire_write(requester, resource)
                    .await
                    .map_err(map_guard_err)?;
                let path = project_context_path(&project);
                self.fs
                    .create_dir_all(&project_context_dir(&project))
                    .await?;
                self.fs.write(&path, content.as_bytes()).await?;
                Ok(ProposeOutcome::Written)
            }
        }
    }

    /// Materialises a rejected global-context change as a proposal file under
    /// `.ideai/proposals/<who>-<ts>.md`, returning its path.
    async fn file_proposal(
        &self,
        project: &Project,
        who: ConversationParty,
        content: &str,
    ) -> Result<String, AppError> {
        let who_label = match who {
            ConversationParty::User => "orchestrator".to_owned(),
            ConversationParty::Agent { agent_id } => agent_id.to_string(),
        };
        let ts = self.clock.now_millis();
        let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
        let dir = join_root(project, PROPOSALS_DIR);
        self.fs.create_dir_all(&dir).await?;
        let path = join_root(project, &rel);
        self.fs.write(&path, content.as_bytes()).await?;
        Ok(path.as_str().to_owned())
    }
}

/// Reads project memory under a shared read-lease.
///
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
pub struct ReadMemory {
    guard: Arc<dyn FileGuard>,
    memory: Arc<dyn MemoryStore>,
}

/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
    /// The project to read within.
    pub project: Project,
    /// Target note slug; `None` ⇒ the aggregated index.
    pub slug: Option<String>,
    /// The reading party.
    pub requester: ConversationParty,
}

impl ReadMemory {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
        Self { guard, memory }
    }

    /// Reads the requested memory, returning its Markdown content.
    ///
    /// # Errors
    /// [`AppError`] when the note does not exist or the store fails. An invalid slug
    /// is [`AppError::Invalid`].
    pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
        let ReadMemoryInput {
            project,
            slug,
            requester,
        } = input;
        match slug {
            Some(raw) => {
                let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
                let _lease = self
                    .guard
                    .acquire_read(requester, GuardedResource::Memory(slug.clone()))
                    .await
                    .map_err(map_guard_err)?;
                let note = self.memory.get(&project.root, &slug).await?;
                Ok(note.body.into_string())
            }
            None => {
                // The aggregated index is project-shared; read it as a rendered list.
                let entries = self.memory.read_index(&project.root).await?;
                let lines: Vec<String> = entries
                    .into_iter()
                    .map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
                    .collect();
                Ok(lines.join("\n"))
            }
        }
    }
}

/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
pub struct WriteMemory {
    guard: Arc<dyn FileGuard>,
    memory: Arc<dyn MemoryStore>,
}

/// Input for [`WriteMemory`].
pub struct WriteMemoryInput {
    /// The project to write within.
    pub project: Project,
    /// Target note slug.
    pub slug: String,
    /// The Markdown body to store.
    pub content: String,
    /// The writing party.
    pub requester: ConversationParty,
}

impl WriteMemory {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
        Self { guard, memory }
    }

    /// Writes the note under a write-lease.
    ///
    /// # Errors
    /// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
    /// failure.
    pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
        let WriteMemoryInput {
            project,
            slug,
            content,
            requester,
        } = input;
        let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
        let _lease = self
            .guard
            .acquire_write(requester, GuardedResource::Memory(slug.clone()))
            .await
            .map_err(map_guard_err)?;
        let frontmatter = MemoryFrontmatter {
            name: slug.clone(),
            description: format!("memory note {slug}"),
            r#type: MemoryType::Project,
        };
        let note = Memory::new(frontmatter, MarkdownDoc::new(content))
            .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.memory.save(&project.root, &note).await?;
        Ok(())
    }
}

/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
/// blocking adapter never returns, but is mapped for completeness.
fn map_guard_err(e: GuardError) -> AppError {
    match e {
        GuardError::Forbidden => AppError::Invalid(
            "writing the global project context is reserved to the orchestrator; propose instead"
                .to_owned(),
        ),
        GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use domain::agent::{AgentManifest, ManifestEntry};
    use domain::conversation::ConversationParty;
    use domain::fileguard::{ReadLease, WriteLease};
    use domain::ports::{EventStream, FsError, MemoryError, StoreError};
    use domain::project::ProjectPath;
    use domain::{ProfileId, ProjectId, RemoteRef};
    use std::collections::HashMap;
    use std::sync::{Arc as StdArc, Mutex};
    use std::time::Duration;
    use tokio::sync::RwLock;

    fn project() -> Project {
        Project::new(
            ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
            "demo",
            ProjectPath::new("/tmp/demo").unwrap(),
            RemoteRef::local(),
            0,
        )
        .unwrap()
    }

    /// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
    /// `RwLock` per resource + the single-writer rule), so the application crate
    /// stays free of any dependency on infrastructure (no dependency cycle).
    #[derive(Default)]
    struct TestGuard {
        locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
    }

    impl TestGuard {
        fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
            self.locks
                .lock()
                .unwrap()
                .entry(res.clone())
                .or_insert_with(|| StdArc::new(RwLock::new(())))
                .clone()
        }
    }

    #[async_trait]
    impl FileGuard for TestGuard {
        async fn acquire_read(
            &self,
            _who: ConversationParty,
            res: GuardedResource,
        ) -> Result<ReadLease, GuardError> {
            let lock = self.lock_for(&res);
            Ok(ReadLease::new(Box::new(lock.read_owned().await)))
        }
        async fn acquire_write(
            &self,
            _who: ConversationParty,
            res: GuardedResource,
        ) -> Result<WriteLease, GuardError> {
            let lock = self.lock_for(&res);
            Ok(WriteLease::new(Box::new(lock.write_owned().await)))
        }
    }

    fn agent_party(n: u128) -> ConversationParty {
        ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
    }

    fn project_context_file() -> &'static str {
        "/tmp/demo/.ideai/CONTEXT.md"
    }

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

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

    #[async_trait]
    impl FileSystem for FakeFs {
        async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
            self.files
                .lock()
                .unwrap()
                .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.files
                .lock()
                .unwrap()
                .insert(path.as_str().to_owned(), data.to_vec());
            Ok(())
        }
        async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
            Ok(self.files.lock().unwrap().contains_key(path.as_str()))
        }
        async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
            self.dirs.lock().unwrap().push(path.as_str().to_owned());
            Ok(())
        }
        async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
            Ok(Vec::new())
        }
        async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
    }

    struct FakeContexts {
        manifest: AgentManifest,
        contexts: Mutex<HashMap<AgentId, String>>,
    }

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

    #[derive(Default)]
    struct FakeMemory {
        notes: Mutex<HashMap<String, String>>,
    }

    #[async_trait]
    impl MemoryStore for FakeMemory {
        async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
            Ok(Vec::new())
        }
        async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
            let body = self
                .notes
                .lock()
                .unwrap()
                .get(slug.as_str())
                .cloned()
                .ok_or(MemoryError::NotFound)?;
            Memory::new(
                MemoryFrontmatter {
                    name: slug.clone(),
                    description: "d".to_owned(),
                    r#type: MemoryType::Project,
                },
                MarkdownDoc::new(body),
            )
            .map_err(|e| MemoryError::Frontmatter(e.to_string()))
        }
        async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
            self.notes
                .lock()
                .unwrap()
                .insert(memory.slug().to_string(), memory.body.as_str().to_owned());
            Ok(())
        }
        async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
            Ok(())
        }
        async fn read_index(
            &self,
            _root: &ProjectPath,
        ) -> Result<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
            Ok(Vec::new())
        }
        async fn resolve_links(
            &self,
            _root: &ProjectPath,
            _slug: &MemorySlug,
        ) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
            Ok(Vec::new())
        }
    }

    struct FixedClock;
    impl Clock for FixedClock {
        fn now_millis(&self) -> i64 {
            42
        }
    }

    #[derive(Default)]
    struct SpyBus(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())
        }
    }

    fn guard() -> Arc<dyn FileGuard> {
        Arc::new(TestGuard::default())
    }

    fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
        let mut contexts = HashMap::new();
        contexts.insert(agent, body.to_owned());
        Arc::new(FakeContexts {
            manifest: AgentManifest {
                version: 1,
                orchestrator: None,
                entries: vec![ManifestEntry {
                    agent_id: agent,
                    name: name.to_owned(),
                    md_path: "agents/x.md".to_owned(),
                    profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
                    template_id: None,
                    synchronized: false,
                    synced_template_version: None,
                    skills: Vec::new(),
                    effort: None,
                }],
            },
            contexts: Mutex::new(contexts),
        })
    }

    // ---- Tests -----------------------------------------------------------

    #[tokio::test]
    async fn read_agent_context_returns_body() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let uc = ReadContext::new(
            guard(),
            contexts_with("Dev", agent, "# hello"),
            Arc::new(FakeFs::default()),
        );
        let out = uc
            .execute(ReadContextInput {
                project: project(),
                target: Some("dev".to_owned()), // case-insensitive
                requester: agent_party(1),
            })
            .await
            .unwrap();
        assert_eq!(out.content.as_str(), "# hello");
        assert_eq!(out.version, None);
    }

    #[tokio::test]
    async fn read_global_context_reads_canonical_context_file_without_root_claude() {
        let fs = Arc::new(FakeFs::default());
        {
            let mut files = fs.files.lock().unwrap();
            files.insert(project_context_file().to_owned(), b"# project".to_vec());
        }
        let uc = ReadContext::new(
            guard(),
            contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
        );
        let out = uc
            .execute(ReadContextInput {
                project: project(),
                target: None,
                requester: ConversationParty::User,
            })
            .await
            .unwrap();
        assert_eq!(out.content.as_str(), "# project");
        assert_eq!(out.version, Some(hex_sha256(b"# project")));
        assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
    }

    #[tokio::test]
    async fn concurrent_reads_do_not_block_each_other() {
        // Two readers on the same global context, held at once. If the read-lease
        // were exclusive this would deadlock; a bounded timeout proves it does not.
        let guard = guard();
        let r1 = guard
            .acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
            .await
            .unwrap();
        let r2 = tokio::time::timeout(
            Duration::from_millis(200),
            guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
        )
        .await
        .expect("a second reader must not block")
        .unwrap();
        drop((r1, r2));
    }

    #[tokio::test]
    async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# original".to_vec());
        let uc = ProposeContext::new(
            guard(),
            contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(FixedClock),
        );
        let outcome = uc
            .execute(ProposeContextInput {
                project: project(),
                target: None,
                content: "# hijack".to_owned(),
                requester: agent_party(3),
            })
            .await
            .unwrap();
        // It is a *proposal*, not a write: the live context is untouched.
        assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# original",
            "the live global context must NOT be overwritten by a proposal"
        );
        // The proposal landed under .ideai/proposals/.
        let files = fs.files.lock().unwrap();
        assert!(files
            .keys()
            .any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
    }

    #[tokio::test]
    async fn orchestrator_writes_global_context_directly() {
        let fs = Arc::new(FakeFs::default());
        let uc = ProposeContext::new(
            guard(),
            contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(FixedClock),
        );
        let outcome = uc
            .execute(ProposeContextInput {
                project: project(),
                target: None,
                content: "# new".to_owned(),
                requester: ConversationParty::User,
            })
            .await
            .unwrap();
        assert_eq!(outcome, ProposeOutcome::Written);
        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# new"
        );
        assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
    }

    #[tokio::test]
    async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# old".to_vec());
        let bus = Arc::new(SpyBus::default());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", agent, "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::clone(&bus) as Arc<dyn EventBus>,
            Arc::new(FixedClock),
        );

        let out = uc
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# new".to_owned(),
                if_match: None,
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap();

        assert_eq!(out.new_version, hex_sha256(b"# new"));
        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# new"
        );
        assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
    }

    #[tokio::test]
    async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# old".to_vec());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(SpyBus::default()),
            Arc::new(FixedClock),
        );

        let err = uc
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# rejected".to_owned(),
                if_match: None,
                requester: agent_party(8),
            })
            .await
            .unwrap_err();

        assert_eq!(err.code(), "INVALID");
        let files = fs.files.lock().unwrap();
        assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
        assert!(
            !files.keys().any(|path| path.contains("/.ideai/proposals/")),
            "strict update must fail loud, not file a proposal"
        );
    }

    #[tokio::test]
    async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# current".to_vec());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", agent, "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(SpyBus::default()),
            Arc::new(FixedClock),
        );

        let err = uc
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# new".to_owned(),
                if_match: Some("stale".to_owned()),
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap_err();

        assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# current"
        );
    }

    #[tokio::test]
    async fn update_project_context_if_match_matching_succeeds() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# old".to_vec());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", agent, "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(SpyBus::default()),
            Arc::new(FixedClock),
        );

        let out = uc
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# new".to_owned(),
                if_match: Some(hex_sha256(b"# old")),
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap();

        assert_eq!(out.new_version, hex_sha256(b"# new"));
        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# new"
        );
    }

    #[tokio::test]
    async fn update_project_context_no_if_match_is_last_write_wins() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# previous".to_vec());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", agent, "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(SpyBus::default()),
            Arc::new(FixedClock),
        );

        uc.execute(UpdateProjectContextInput {
            project: project(),
            content: "# latest".to_owned(),
            if_match: None,
            requester: ConversationParty::agent(agent),
        })
        .await
        .unwrap();

        assert_eq!(
            fs.files
                .lock()
                .unwrap()
                .get(project_context_file())
                .unwrap(),
            b"# latest"
        );
    }

    #[tokio::test]
    async fn update_project_context_publishes_project_context_updated_event() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# old".to_vec());
        let bus = Arc::new(SpyBus::default());
        let uc = UpdateProjectContext::new(
            guard(),
            contexts_with("Dev", agent, "x"),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::clone(&bus) as Arc<dyn EventBus>,
            Arc::new(FixedClock),
        );

        uc.execute(UpdateProjectContextInput {
            project: project(),
            content: "# new".to_owned(),
            if_match: None,
            requester: ConversationParty::agent(agent),
        })
        .await
        .unwrap();

        assert_eq!(
            bus.events(),
            vec![DomainEvent::ProjectContextUpdated {
                project_id: project().id,
                by: ConversationParty::agent(agent),
                at_ms: 42,
            }]
        );
    }

    #[tokio::test]
    async fn read_context_and_update_project_context_version_round_trip() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let fs = Arc::new(FakeFs::default());
        fs.files
            .lock()
            .unwrap()
            .insert(project_context_file().to_owned(), b"# first".to_vec());
        let contexts = contexts_with("Dev", agent, "agent body");
        let reader = ReadContext::new(
            guard(),
            Arc::clone(&contexts),
            Arc::clone(&fs) as Arc<dyn FileSystem>,
        );
        let updater = UpdateProjectContext::new(
            guard(),
            contexts,
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            Arc::new(SpyBus::default()),
            Arc::new(FixedClock),
        );

        let version = reader
            .execute(ReadContextInput {
                project: project(),
                target: None,
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap()
            .version
            .unwrap();

        updater
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# second".to_owned(),
                if_match: Some(version.clone()),
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap();
        let stale = updater
            .execute(UpdateProjectContextInput {
                project: project(),
                content: "# third".to_owned(),
                if_match: Some(version),
                requester: ConversationParty::agent(agent),
            })
            .await
            .unwrap_err();

        assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
    }

    #[tokio::test]
    async fn propose_agent_context_writes_directly() {
        let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
        let contexts = contexts_with("Dev", agent, "# old");
        let uc = ProposeContext::new(
            guard(),
            Arc::clone(&contexts),
            Arc::new(FakeFs::default()),
            Arc::new(FixedClock),
        );
        let outcome = uc
            .execute(ProposeContextInput {
                project: project(),
                target: Some("Dev".to_owned()),
                content: "# new body".to_owned(),
                requester: agent_party(3),
            })
            .await
            .unwrap();
        assert_eq!(outcome, ProposeOutcome::Written);
        assert_eq!(
            contexts
                .read_context(&project(), &agent)
                .await
                .unwrap()
                .as_str(),
            "# new body"
        );
    }

    #[tokio::test]
    async fn write_then_read_memory_round_trips_under_guard() {
        let memory = Arc::new(FakeMemory::default());
        let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
        writer
            .execute(WriteMemoryInput {
                project: project(),
                slug: "note-a".to_owned(),
                content: "body".to_owned(),
                requester: agent_party(2),
            })
            .await
            .unwrap();

        let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
        let body = reader
            .execute(ReadMemoryInput {
                project: project(),
                slug: Some("note-a".to_owned()),
                requester: agent_party(2),
            })
            .await
            .unwrap();
        assert_eq!(body, "body");
    }

    #[tokio::test]
    async fn writes_to_same_memory_note_serialise() {
        // Two write leases on the same note must not overlap (exclusive writer).
        let guard = guard();
        let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
        let w1 = guard
            .acquire_write(agent_party(1), slug.clone())
            .await
            .unwrap();
        // While w1 is held, a second writer blocks; it only succeeds after release.
        let blocked = tokio::time::timeout(
            Duration::from_millis(100),
            guard.acquire_write(agent_party(2), slug.clone()),
        )
        .await;
        assert!(
            blocked.is_err(),
            "a second writer must block while w1 holds"
        );
        drop(w1);
        let w2 = tokio::time::timeout(
            Duration::from_millis(200),
            guard.acquire_write(agent_party(2), slug),
        )
        .await
        .expect("w2 acquires after w1 releases")
        .unwrap();
        drop(w2);
    }
}
