//! File-access guard (cadrage « FileGuard », lot C6).
//!
//! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns:
//! an agent's `.md` context, the project's global context, and a memory note.
//! N concurrent readers **or** a single exclusive writer per resource; the global
//! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the
//! orchestrator may `acquire_write` it — any other party must *propose* instead and
//! receives [`GuardError::Forbidden`].
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard corrects collisions **inside the IdeA path** (access through the MCP
//! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can
//! still write these `.md`/memory files directly through the filesystem, bypassing
//! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS
//! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of
//! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent.
//!
//! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects
//! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`],
//! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`)
//! provides the actual reader/writer locks.

use async_trait::async_trait;

use crate::conversation::ConversationParty;
use crate::ids::AgentId;
use crate::memory::MemorySlug;

/// The **bounded, closed** set of resources the [`FileGuard`] arbitrates.
///
/// Closed by construction: only an agent's context, the global project context, and
/// a memory note are guardable. Anything outside this enum is **not** guarded (and
/// must not be routed through the guard).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum GuardedResource {
    /// One agent's `.md` context file.
    AgentContext(AgentId),
    /// The project's global context (`CLAUDE.md`/root context).
    ///
    /// **Single-writer**: only the orchestrator may write it; every other party is
    /// limited to *proposing* a change (cf. [`GuardError::Forbidden`]).
    ProjectContext,
    /// A single memory note, keyed by its slug.
    Memory(MemorySlug),
}

impl GuardedResource {
    /// Whether this resource is the single-writer global project context.
    #[must_use]
    pub const fn is_project_context(&self) -> bool {
        matches!(self, Self::ProjectContext)
    }
}

/// Errors raised when acquiring a guard lease.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GuardError {
    /// The lease could not be taken because the resource is held incompatibly
    /// (a writer wanted while readers/a writer hold it, or vice-versa) and the
    /// caller asked for a **non-blocking** acquire. Blocking acquires serialize
    /// instead of returning this.
    #[error("guarded resource is busy")]
    Busy,
    /// A party that is **not** the orchestrator attempted to `acquire_write` the
    /// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
    /// change for validation rather than writing directly.
    #[error("writing the global project context is reserved to the orchestrator; propose instead")]
    Forbidden,
}

/// A RAII **read** lease: holding it guarantees shared read access to the resource;
/// dropping it releases the reader slot. N read leases may coexist on one resource.
///
/// The lease owns an opaque release guard (a boxed handle the adapter fills with its
/// lock guard). The domain only knows the lease releases on drop.
#[must_use = "a read lease releases the guard as soon as it is dropped"]
pub struct ReadLease {
    _guard: Box<dyn Send + Sync>,
}

impl ReadLease {
    /// Wraps an adapter-provided release guard into a domain [`ReadLease`].
    ///
    /// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`);
    /// the domain treats it opaquely and only relies on its `Drop` to release.
    #[must_use]
    pub fn new(guard: Box<dyn Send + Sync>) -> Self {
        Self { _guard: guard }
    }
}

impl std::fmt::Debug for ReadLease {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("ReadLease")
    }
}

/// A RAII **write** lease: holding it guarantees exclusive write access to the
/// resource; dropping it releases the writer slot. At most one write lease exists
/// for a resource at a time, and it excludes all readers.
#[must_use = "a write lease releases the guard as soon as it is dropped"]
pub struct WriteLease {
    _guard: Box<dyn Send + Sync>,
}

impl WriteLease {
    /// Wraps an adapter-provided release guard into a domain [`WriteLease`].
    #[must_use]
    pub fn new(guard: Box<dyn Send + Sync>) -> Self {
        Self { _guard: guard }
    }
}

impl std::fmt::Debug for WriteLease {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("WriteLease")
    }
}

/// A reader/writer guard over the bounded set of IdeA-owned files
/// ([`GuardedResource`]).
///
/// Contract:
/// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one
///   resource concurrently.
/// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other
///   readers and writers of the **same** resource (different resources are
///   independent).
/// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a
///   `who` that is not the orchestrator returns [`GuardError::Forbidden`].
///
/// Object-safe (held as `Arc<dyn FileGuard>`); blocking acquires serialize, so the
/// only error is `Forbidden` (the cooperative path never spuriously fails a wait).
#[async_trait]
pub trait FileGuard: Send + Sync {
    /// Acquires a shared **read** lease on `res` for `who`. Serializes behind any
    /// in-flight writer of the same resource, then returns a [`ReadLease`].
    ///
    /// # Errors
    /// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns
    /// `Ok` once the writer (if any) releases.
    async fn acquire_read(
        &self,
        who: ConversationParty,
        res: GuardedResource,
    ) -> Result<ReadLease, GuardError>;

    /// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind
    /// any in-flight readers/writer of the same resource.
    ///
    /// # Errors
    /// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the
    /// single-writer [`GuardedResource::ProjectContext`].
    async fn acquire_write(
        &self,
        who: ConversationParty,
        res: GuardedResource,
    ) -> Result<WriteLease, GuardError>;
}

/// **Which agent (if any) the project designates as its orchestrator.**
///
/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du
/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator
/// and is never represented here.
/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator.
/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio
///   selection, single agent by construction — the illegal "two orchestrators" state
///   is unrepresentable).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OrchestratorDesignation(Option<AgentId>);

impl OrchestratorDesignation {
    /// No agent designated: only the human ([`ConversationParty::User`]) orchestrates.
    #[must_use]
    pub const fn none() -> Self {
        Self(None)
    }

    /// Designates `agent` as the project's orchestrator (radio selection).
    #[must_use]
    pub const fn of(agent: AgentId) -> Self {
        Self(Some(agent))
    }

    /// The designated agent, if any. `None` ⇒ default (human-only orchestrator).
    #[must_use]
    pub const fn designated(&self) -> Option<AgentId> {
        self.0
    }
}

/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
///
/// Pure policy, shared by the port's documented contract and the adapter: only an
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
/// may write the per-agent context and memory. Orchestrator identity is resolved
/// against the project's [`OrchestratorDesignation`] (the human is always one; a
/// project agent only when explicitly designated).
#[must_use]
pub fn may_write_directly(
    who: ConversationParty,
    res: &GuardedResource,
    d: &OrchestratorDesignation,
) -> bool {
    if res.is_project_context() {
        is_orchestrator(who, d)
    } else {
        true
    }
}

/// Whether `who` is an orchestrator identity for the single-writer rule, given the
/// project's [`OrchestratorDesignation`] `d`.
///
/// The human-driven operator ([`ConversationParty::User`]) is **always** an
/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator
/// **only** when it is the designated one; otherwise it must propose changes to the
/// global context.
#[must_use]
pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool {
    match who {
        ConversationParty::User => true,
        ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

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

    #[test]
    fn project_context_is_single_writer_to_orchestrator_only() {
        // Single-writer rule preserved with the default (no designated agent):
        // the human writes, every agent is refused.
        let default = OrchestratorDesignation::none();
        assert!(may_write_directly(
            ConversationParty::User,
            &GuardedResource::ProjectContext,
            &default,
        ));
        assert!(!may_write_directly(
            agent_party(1),
            &GuardedResource::ProjectContext,
            &default,
        ));
    }

    #[test]
    fn designated_agent_may_write_project_context() {
        let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
        let designation = OrchestratorDesignation::of(id);
        // The designated agent now writes the global context directly…
        assert!(may_write_directly(
            ConversationParty::agent(id),
            &GuardedResource::ProjectContext,
            &designation,
        ));
        // …while another agent stays refused, and the human stays allowed.
        assert!(!may_write_directly(
            agent_party(2),
            &GuardedResource::ProjectContext,
            &designation,
        ));
        assert!(may_write_directly(
            ConversationParty::User,
            &GuardedResource::ProjectContext,
            &designation,
        ));
    }

    #[test]
    fn agent_context_and_memory_are_writable_by_anyone() {
        let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
        let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
        let default = OrchestratorDesignation::none();
        for who in [ConversationParty::User, agent_party(1)] {
            assert!(may_write_directly(who, &mem, &default));
            assert!(may_write_directly(who, &ctx, &default));
        }
    }

    #[test]
    fn is_orchestrator_for_user_and_designated_agent() {
        let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
        // Human is always an orchestrator, designation or not.
        assert!(is_orchestrator(
            ConversationParty::User,
            &OrchestratorDesignation::none()
        ));
        // An agent is an orchestrator only when it is the designated one.
        assert!(!is_orchestrator(
            ConversationParty::agent(id),
            &OrchestratorDesignation::none()
        ));
        assert!(is_orchestrator(
            ConversationParty::agent(id),
            &OrchestratorDesignation::of(id)
        ));
        assert!(!is_orchestrator(
            agent_party(2),
            &OrchestratorDesignation::of(id)
        ));
    }

    #[test]
    fn guarded_resource_equality_keys_on_identity() {
        let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
        let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
        let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2)));
        assert_eq!(a, b);
        assert_ne!(a, c);
        assert_ne!(
            GuardedResource::ProjectContext,
            GuardedResource::Memory(MemorySlug::new("x").unwrap())
        );
    }
}
