//! Ports (traits) — the boundary the domain defines and the infrastructure
//! implements (the **D** of SOLID, materialised).
//!
//! # Async decision
//!
//! Ports that touch the outside world (PTY, filesystem, process, stores, git,
//! remote connect) are inherently asynchronous in every realistic adapter
//! (tokio fs/process, russh, sftp). We therefore make those traits `async` via
//! [`async_trait`]. The rationale for `#[async_trait]` over native
//! `async fn` in traits / `-> impl Future`:
//!
//! - These ports are consumed as **trait objects** (`Arc<dyn FileSystem>`,
//!   injected at the composition root). Native `async fn` in traits is not yet
//!   dyn-compatible without boxing, so `async_trait` (which boxes the future)
//!   is the pragmatic, stable choice and keeps the call sites object-safe.
//! - It keeps signatures readable and uniform across all adapters.
//!
//! Purely synchronous, non-blocking ports ([`Clock`], [`IdGenerator`],
//! [`EventBus`], the CPU-bound part of [`AgentRuntime`]) stay plain `fn` — no
//! need to pay the boxing cost.
//!
//! Each port is **fine-grained** (Interface Segregation): `FileSystem`,
//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;

use crate::agent::AgentManifest;
use crate::agent_tool_policy::AgentToolPolicy;
use crate::background_task::{
    BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
};
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
use crate::events::DomainEvent;
use crate::ids::{
    AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
};
use crate::issue::{
    Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::model_server::{
    HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
};
use crate::permission::ProjectPermissions;
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
use crate::template::AgentTemplate;
use crate::terminal::PtySize;

// ---------------------------------------------------------------------------
// Support value types shared across ports
// ---------------------------------------------------------------------------

/// How the `.md` context should be delivered to a spawned process, resolved
/// from a profile's [`crate::profile::ContextInjection`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextInjectionPlan {
    /// Materialise the context at a (relative) file path before launch.
    File {
        /// Relative target path inside the cwd.
        target: String,
    },
    /// Append the given already-rendered arguments to the command line.
    Args {
        /// Extra arguments carrying the context path.
        args: Vec<String>,
    },
    /// Pipe the content on stdin.
    Stdin,
    /// Provide the content (or its path) via an environment variable.
    Env {
        /// Variable name.
        var: String,
    },
}

/// Intention de session pour un lancement d'agent donné.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionPlan {
    /// Pas de reprise : profil sans bloc `session`, ou cellule neuve sans id.
    None,
    /// Premier lancement : IdeA a généré `conversation_id`, à assigner via assign_flag.
    Assign {
        /// Identifiant de conversation généré par IdeA pour ce lancement.
        conversation_id: String,
    },
    /// Réouverture : reprendre la conversation existante via resume_flag.
    Resume {
        /// Identifiant de la conversation existante à reprendre.
        conversation_id: String,
    },
}

/// A fully-resolved process invocation: command, args, cwd, environment, and the
/// plan for delivering the agent context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnSpec {
    /// Executable to run.
    pub command: String,
    /// Arguments.
    pub args: Vec<String>,
    /// Working directory.
    pub cwd: ProjectPath,
    /// Extra environment variables.
    pub env: Vec<(String, String)>,
    /// How the context is injected, if any.
    pub context_plan: Option<ContextInjectionPlan>,
    /// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no
    /// enforcement (the agent runs natively); the field is wired but unused until
    /// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it.
    pub sandbox: Option<crate::sandbox::SandboxPlan>,
}

/// The agent context prepared for injection (content + the on-disk path it maps
/// to within the project).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedContext {
    /// Rendered Markdown content.
    pub content: MarkdownDoc,
    /// Relative path of the `.md` inside the project.
    pub relative_path: String,
    /// Absolute root of the project this context belongs to.
    pub project_root: String,
}

/// Prepared environment for a structured assistant session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionEnvironment {
    /// Isolated cwd where the structured CLI must run.
    pub cwd: ProjectPath,
    /// Environment variables to pass to the structured session.
    pub env: Vec<(String, String)>,
}

/// Errors returned while preparing the IdeA-owned ticket assistant context.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AssistantContextError {
    /// The context source was missing and no embedded default could be used.
    #[error("assistant context not found")]
    NotFound,
    /// The context could not be read or rendered.
    #[error("assistant context store failed: {0}")]
    Store(String),
}

/// Provides the IdeA-owned context used by ticket assistant sessions.
#[async_trait]
pub trait AssistantContextProvider: Send + Sync {
    /// Renders a prepared context for a ticket assistant bound to `issue`.
    ///
    /// # Errors
    /// [`AssistantContextError`] when the context cannot be read or rendered.
    async fn prepare_ticket_assistant_context(
        &self,
        project: &Project,
        issue: &Issue,
    ) -> Result<PreparedContext, AssistantContextError>;
}

/// Prepares the isolated run environment for a structured ticket assistant.
#[async_trait]
pub trait StructuredSessionEnvironmentPreparer: Send + Sync {
    /// Materialises context and MCP configuration for a ticket assistant session.
    ///
    /// # Errors
    /// [`RuntimeError`] when the structured environment cannot be prepared.
    async fn prepare_ticket_assistant(
        &self,
        project: &Project,
        issue_ref: IssueRef,
        profile: &AgentProfile,
        prepared: &PreparedContext,
        requester: &str,
    ) -> Result<StructuredSessionEnvironment, RuntimeError>;
}

/// Stores requester-scoped MCP tool policies for ephemeral assistant sessions.
pub trait AgentToolPolicyStore: Send + Sync {
    /// Sets or replaces the policy for `requester`.
    fn set_policy(&self, requester: String, policy: AgentToolPolicy);

    /// Returns the policy for `requester`, if any.
    fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy>;

    /// Clears the policy for `requester`.
    fn clear_policy(&self, requester: &str);
}

/// Enriched, **best-effort** details about a conversation, specific to a CLI's
/// on-disk transcript format (CONTEXT §T6).
///
/// Every field is optional: the core never *requires* this data, and a missing
/// piece (or a missing inspector) must never block a resume. The values exist
/// purely to enrich a resume popup (last topic, token indicator).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationDetails {
    /// A short, best-effort label for the conversation (e.g. the last user
    /// message, truncated). `None` when it cannot be extracted.
    pub last_topic: Option<String>,
    /// A best-effort cumulative token count for the conversation. `None` when no
    /// usage information is available.
    pub token_count: Option<u64>,
}

/// An opaque handle to a live PTY, owned by the adapter.
///
/// The domain only needs an identity to address the PTY in subsequent calls;
/// the actual OS handle lives in infrastructure.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PtyHandle {
    /// The session this PTY backs.
    pub session_id: SessionId,
}

/// Exit status of a process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExitStatus {
    /// Exit code (`None` if terminated by a signal).
    pub code: Option<i32>,
}

/// Captured output of a non-interactive process run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
    /// Exit status.
    pub status: ExitStatus,
    /// Captured stdout.
    pub stdout: Vec<u8>,
    /// Captured stderr.
    pub stderr: Vec<u8>,
}

/// Opaque handle for a managed local process.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ManagedProcessHandle {
    /// Stable local process identity as assigned by the adapter.
    pub id: String,
}

/// Observed status of a managed local process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessStatus {
    /// Process is still running.
    Running,
    /// Process has exited.
    Exited {
        /// Exit code, when available.
        code: Option<i32>,
    },
    /// Handle is unknown to the adapter.
    Unknown,
}

/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemotePath(pub String);

impl RemotePath {
    /// Wraps a raw path.
    #[must_use]
    pub fn new(p: impl Into<String>) -> Self {
        Self(p.into())
    }

    /// Returns the path as a string slice.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A single directory entry returned by [`FileSystem::list`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
    /// Entry name (basename).
    pub name: String,
    /// Whether the entry is a directory.
    pub is_dir: bool,
}

/// An owned, boxed stream of PTY output chunks.
///
/// Concrete adapters decide the underlying transport; the domain only sees a
/// dynamically-dispatched iterator of byte chunks.
pub type OutputStream = Box<dyn Iterator<Item = Vec<u8>> + Send>;

/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;

/// A boxed stream of background task completions.
pub type BackgroundCompletionStream = Box<dyn Iterator<Item = BackgroundTaskCompletion> + Send>;

/// Specification passed to a background task runner.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackgroundTaskSpec {
    /// Task id allocated by the application.
    pub task_id: TaskId,
    /// Owning project.
    pub project_id: ProjectId,
    /// Agent that owns completion delivery.
    pub owner_agent_id: AgentId,
    /// Kind-specific task payload.
    pub kind: BackgroundTaskKind,
    /// Completion wake policy.
    pub wake_policy: BackgroundTaskWakePolicy,
    /// Optional command invocation for command-backed tasks.
    pub command: Option<SpawnSpec>,
    /// Optional absolute deadline, epoch milliseconds.
    pub deadline_ms: Option<u64>,
}

/// Opaque runner handle for a spawned background task.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackgroundTaskHandle {
    /// Spawned task id.
    pub task_id: TaskId,
}

/// Terminal completion reported by a background runner.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackgroundTaskCompletion {
    /// Completed task id.
    pub task_id: TaskId,
    /// Terminal result.
    pub result: BackgroundTaskResult,
}

/// Why IdeA wakes an agent outside a human/delegation submit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeReason {
    /// A persisted background task completion is ready in the owner's inbox.
    BackgroundCompletion {
        /// Completed background task.
        task_id: TaskId,
    },
    /// A generic queued inbox item should be processed.
    InboxItem {
        /// Queued item id.
        item_id: crate::mailbox::TicketId,
    },
}

/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
///
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
/// vers ces variantes ; **aucun** détail propre à une CLI (pas de `stream-json`,
/// pas de `--output-format`, pas de chemin de transcript) ne franchit cette
/// frontière domaine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplyEvent {
    /// Un fragment de texte assistant (rendu incrémental côté UI chat).
    TextDelta {
        /// Le fragment de texte.
        text: String,
    },
    /// Une activité d'outil de l'agent (best-effort, pour l'observabilité chat :
    /// « lit un fichier », « lance une commande »). Le `label` est déjà
    /// humain-lisible ; le détail brut reste dans l'adapter.
    ToolActivity {
        /// Libellé humain-lisible de l'activité.
        label: String,
    },
    /// Message assistant intermédiaire complet, destiné à l'observabilité live d'un
    /// rendez-vous inter-agent. Non terminal : seul [`ReplyEvent::Final`] clôt le tour.
    Announcement {
        /// Texte complet de l'annonce.
        text: String,
    },
    /// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
    /// toujours en train de travailler. Émis par l'adapter quand le moteur signale
    /// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
    /// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
    /// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
    ///
    /// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
    /// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
    /// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
    /// d'obligation d'en émettre.
    Heartbeat,
    /// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) :
    /// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de
    /// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent).
    /// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre
    /// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de
    /// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2).
    ///
    /// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`],
    /// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue
    /// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence
    /// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit
    /// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux
    /// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour.
    RateLimited {
        /// Instant de réinitialisation de la limite, en **époche-millisecondes**
        /// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas
        /// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet
        /// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1).
        resets_at_ms: Option<i64>,
    },
    /// Erreur terminale du moteur structurée et affichable côté chat. Porte un
    /// message déjà nettoyé par l'adapter ; les détails propres à la CLI restent
    /// confinés côté infrastructure.
    Error {
        /// Message humain-lisible à rendre dans la cellule chat.
        message: String,
    },
    /// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
    /// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
    /// Après `Final`, le flux se termine (plus aucun événement).
    Final {
        /// Le contenu final agrégé du tour.
        content: String,
    },
}

/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
/// après le [`ReplyEvent::Final`] / [`ReplyEvent::Error`] (ou sur erreur de transport).
/// Calqué sur [`OutputStream`],
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
/// **un** terminal déterministe, plutôt que des octets bruts. `Final` clôt un tour
/// réussi ; `Error` clôt un tour échoué avec un message visible. Deltas, activités
/// et heartbeats sont tous non terminaux.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;

/// Description pure d'un outil exposé à un modèle OpenAI-compatible.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSpec {
    /// Nom stable de l'outil (ex. `idea_ask_agent`).
    pub name: String,
    /// Description destinée au modèle.
    pub description: String,
    /// Schéma JSON d'entrée de l'outil.
    pub input_schema: Value,
}

/// Erreurs du port d'invocation d'outils agentiques.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ToolInvocationError {
    /// Outil inconnu ou non exposé par l'orchestrateur.
    #[error("tool not found: {0}")]
    NotFound(String),
    /// Arguments invalides ou non conformes au schéma attendu.
    #[error("tool invalid arguments: {0}")]
    InvalidArguments(String),
    /// Refus par gating produit/permissions.
    #[error("tool rejected: {0}")]
    Rejected(String),
    /// Échec d'exécution de l'outil.
    #[error("tool execution failed: {0}")]
    Execution(String),
}

/// Port pur d'invocation des outils `idea_*` pour les modèles qui n'ont pas de
/// client MCP natif. L'impl concrète vit hors domaine.
#[async_trait]
pub trait ToolInvoker: Send + Sync {
    /// Liste des outils exposés au modèle.
    fn tools(&self) -> Vec<ToolSpec>;

    /// Appelle un outil avec ses arguments JSON bruts.
    ///
    /// # Errors
    /// [`ToolInvocationError`] si l'outil est refusé, introuvable ou échoue.
    async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError>;
}

/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
/// [`Scheduler`] (ARCHITECTURE §21.4).
///
/// **Donnée pure, pas de closure** : à l'image du dispatch de l'orchestrateur
/// (§14.3, où une requête est une *donnée* validée puis dispatchée), une tâche
/// programmée est une **valeur** qui franchit la frontière, jamais un effet ni une
/// fonction. C'est ce qui garde le port [`Scheduler`] sans aucune dépendance vers
/// l'application : l'adapter ne fait que **remettre** cette donnée à un drain
/// applicatif, qui seul l'exécute.
///
/// Enum **extensible** : d'autres intentions programmées pourront s'ajouter sans
/// toucher au port (Open/Closed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScheduledTask {
    /// Reprendre un agent à l'heure de reset de sa limite de session (§21) : relancer
    /// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
    /// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
    ResumeAgent {
        /// L'agent à reprendre.
        agent_id: AgentId,
        /// La cellule (nœud du layout) qui héberge sa session.
        node_id: NodeId,
        /// Id de conversation du moteur à reprendre (`None` ⇒ reprise dégradée sans
        /// id, comme le reste du chemin de reprise §15).
        conversation_id: Option<String>,
    },
}

// ---------------------------------------------------------------------------
// Per-port error types
// ---------------------------------------------------------------------------

/// Errors from [`AgentRuntime`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RuntimeError {
    /// The configured command could not be resolved/detected.
    #[error("agent runtime: {0}")]
    Detection(String),
    /// The invocation could not be prepared (bad profile, etc.).
    #[error("agent runtime: invalid invocation: {0}")]
    Invocation(String),
}

/// Errors from [`PtyPort`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PtyError {
    /// Failed to spawn the PTY.
    #[error("pty spawn failed: {0}")]
    Spawn(String),
    /// Read/write failure.
    #[error("pty io failed: {0}")]
    Io(String),
    /// The handle referred to no live PTY.
    #[error("pty handle not found")]
    NotFound,
}

/// Errors from an [`AgentSession`] / [`AgentSessionFactory`] (ARCHITECTURE §17.1).
///
/// Frontière nette : on ne propage **jamais** le JSON brut d'une CLI à travers
/// ces erreurs (cf. [`AgentSessionError::Decode`]).
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AgentSessionError {
    /// La session programmatique n'a pas pu démarrer (CLI introuvable, mode
    /// structuré indisponible, handshake invalide).
    #[error("agent session start failed: {0}")]
    Start(String),
    /// Échec d'envoi/de communication avec la session vivante.
    #[error("agent session io failed: {0}")]
    Io(String),
    /// La sortie structurée de la CLI n'a pas pu être décodée (JSON cassé, schéma
    /// inattendu). On ne propage jamais le JSON brut.
    #[error("agent session decode failed: {0}")]
    Decode(String),
    /// `send_blocking` n'a pas observé de [`ReplyEvent::Final`] dans le temps
    /// imparti. La session **reste vivante** (on ne tue rien) ; l'appelant décide.
    #[error("agent session reply timed out")]
    Timeout,
}

/// Errors from [`ProcessSpawner`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ProcessError {
    /// The process could not be started.
    #[error("process spawn failed: {0}")]
    Spawn(String),
    /// I/O failure while running.
    #[error("process io failed: {0}")]
    Io(String),
}

/// Errors from local model-server ports.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ModelServerError {
    /// The referenced configuration does not exist.
    #[error("not configured")]
    NotConfigured,
    /// Server or configuration is invalid.
    #[error("invalid: {0}")]
    Invalid(String),
    /// Filesystem permission denied.
    #[error("permission denied: {0}")]
    PermissionDenied(String),
    /// Required path is not accessible.
    #[error("path not accessible: {0}")]
    PathNotAccessible(String),
    /// The configured port is already in use by another managed server.
    #[error("port occupied: {0}")]
    PortOccupied(u16),
    /// The server is still referenced by another configuration object.
    #[error("model server in use: {0}")]
    InUse(String),
    /// Readiness probe failed unexpectedly.
    #[error("probe failed: {0}")]
    Probe(String),
    /// Process operation failed.
    #[error("process failed: {0}")]
    Process(String),
    /// Store operation failed.
    #[error("store failed: {0}")]
    Store(String),
    /// Operation was cancelled by the caller.
    #[error("cancelled")]
    Cancelled,
    /// Readiness timed out.
    #[error("readiness timed out")]
    Timeout,
}

/// Errors from [`FileSystem`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum FsError {
    /// Path not found.
    #[error("path not found: {0}")]
    NotFound(String),
    /// Permission denied.
    #[error("permission denied: {0}")]
    PermissionDenied(String),
    /// Other I/O error.
    #[error("filesystem io failed: {0}")]
    Io(String),
}

/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
/// [`AgentContextStore`], [`SkillStore`]).
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StoreError {
    /// The requested item was not found.
    #[error("not found")]
    NotFound,
    /// (De)serialisation failed.
    #[error("serialization failed: {0}")]
    Serialization(String),
    /// Underlying I/O error.
    #[error("store io failed: {0}")]
    Io(String),
}

/// Errors from the [`MemoryStore`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum MemoryError {
    /// The requested note was not found.
    #[error("memory not found")]
    NotFound,
    /// A note's frontmatter could not be parsed or validated.
    #[error("memory frontmatter error: {0}")]
    Frontmatter(String),
    /// Underlying I/O error.
    #[error("memory io failed: {0}")]
    Io(String),
    /// (De)serialisation of the index or another structured part failed.
    #[error("memory serialization failed: {0}")]
    Serialization(String),
}

/// Errors from an [`Embedder`] (LOT C, étage 2 vectoriel).
///
/// Best-effort by contract: a [`MemoryRecall`] that composes an embedder must
/// **degrade**, never fail hard, on any of these — an unavailable engine or an
/// unimplemented strategy maps to a fallback on the naïve recall (it must never
/// surface as a hard recall error). See [`Embedder`] and the `VectorMemoryRecall`
/// / `AdaptiveMemoryRecall` adapters.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EmbedderError {
    /// The embedding engine is not installed / not reachable (e.g. a local ONNX
    /// model file is missing, or a remote embedding server / API is unreachable).
    #[error("embedder unavailable: {0}")]
    Unavailable(String),
    /// The requested strategy is not implemented yet (the concrete `localOnnx` /
    /// `localServer` / `api` backends ship as documented stubs returning this —
    /// never a panic). Real ONNX/HTTP integration is an explicit follow-up.
    #[error("embedder strategy unsupported: {0}")]
    Unsupported(String),
    /// An I/O failure while producing embeddings.
    #[error("embedder io failed: {0}")]
    Io(String),
}

/// Errors from [`RemoteHost`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RemoteError {
    /// Connection failed.
    #[error("remote connection failed: {0}")]
    Connection(String),
    /// Authentication failed.
    #[error("remote authentication failed: {0}")]
    Auth(String),
}

/// Errors from a [`SessionInspector`].
///
/// Inspection is **best-effort and optional** (CONTEXT §T6/T7): these errors
/// must never block a conversation resume. A caller routing several inspectors
/// simply treats any error as "no enriched details available".
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum InspectError {
    /// No transcript was found for the requested conversation.
    #[error("conversation transcript not found")]
    NotFound,
    /// The transcript could not be read.
    #[error("conversation transcript read failed: {0}")]
    Read(String),
}

/// Errors from the git [`GitPort`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum GitError {
    /// The repository was not found / not initialised.
    #[error("git repository not found")]
    NotFound,
    /// A git operation failed.
    #[error("git operation failed: {0}")]
    Operation(String),
}

/// Errors from first-class background task ports.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum BackgroundTaskPortError {
    /// The requested task was not found.
    #[error("background task not found")]
    NotFound,
    /// A task with this id already exists.
    #[error("background task already exists")]
    AlreadyExists,
    /// The task or request is invalid.
    #[error("background task invalid: {0}")]
    Invalid(String),
    /// The runner failed.
    #[error("background task runner failed: {0}")]
    Runner(String),
    /// The store failed.
    #[error("background task store failed: {0}")]
    Store(String),
}

/// Errors from the owner wake port.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WakeError {
    /// The owner is already running a turn; the inbox item must stay queued.
    #[error("agent {agent_id} is busy; wake skipped")]
    AgentBusy {
        /// Target agent.
        agent_id: AgentId,
    },
    /// There is no queued item to drain for this wake.
    #[error("no pending inbox item for agent {agent_id}")]
    NoPendingItem {
        /// Target agent.
        agent_id: AgentId,
    },
    /// The drained inbox item does not match the requested wake reason.
    #[error("unexpected inbox item for wake: {0}")]
    UnexpectedInboxItem(String),
    /// Session lookup or launch failed.
    #[error("wake session failed: {0}")]
    Session(String),
    /// Background task store failed.
    #[error("wake store failed: {0}")]
    Store(String),
    /// Background task payload was missing or invalid for wake delivery.
    #[error("wake task invalid: {0}")]
    Task(String),
}

/// Errors from the issue store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IssueStoreError {
    /// The requested issue does not exist.
    #[error("issue not found")]
    NotFound,
    /// Optimistic concurrency conflict.
    #[error("issue version conflict: expected {expected}, actual {actual}")]
    VersionConflict {
        /// Expected version.
        expected: IssueVersion,
        /// Actual persisted version.
        actual: IssueVersion,
    },
    /// The issue payload is invalid.
    #[error("issue invalid: {0}")]
    Invalid(String),
    /// Store I/O or serialization failed.
    #[error("issue store failed: {0}")]
    Store(String),
}

/// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError {
    /// The requested sprint does not exist.
    #[error("sprint not found")]
    NotFound,
    /// Optimistic concurrency conflict.
    #[error("sprint version conflict: expected {expected}, actual {actual}")]
    VersionConflict {
        /// Expected version.
        expected: SprintVersion,
        /// Actual persisted version.
        actual: SprintVersion,
    },
    /// The sprint payload is invalid.
    #[error("sprint invalid: {0}")]
    Invalid(String),
    /// Store I/O or serialization failed.
    #[error("sprint store failed: {0}")]
    Store(String),
}
// ---------------------------------------------------------------------------
// Git port support types
// ---------------------------------------------------------------------------

/// Status of a single path in the working tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitFileStatus {
    /// Path relative to the repo root.
    pub path: String,
    /// Whether the change is staged.
    pub staged: bool,
}

/// A commit summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitInfo {
    /// Commit hash.
    pub hash: String,
    /// Commit message summary.
    pub summary: String,
}

/// A commit enriched for graph display (DAG edges + refs + author + time).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphCommit {
    /// Full commit hash (hex string).
    pub hash: String,
    /// First line of the commit message.
    pub summary: String,
    /// Parent commit hashes (0 for root, ≥2 for merges).
    pub parents: Vec<String>,
    /// Human-readable ref labels pointing at this commit
    /// (e.g. `"main"`, `"tag: v1.0"`).
    pub refs: Vec<String>,
    /// Author name (empty string when unavailable).
    pub author: String,
    /// Author timestamp in Unix seconds.
    pub timestamp: i64,
}

// ---------------------------------------------------------------------------
// Ports
// ---------------------------------------------------------------------------

/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
/// injection.
#[async_trait]
pub trait AgentRuntime: Send + Sync {
    /// Detects whether the profile's command is available.
    ///
    /// # Errors
    /// [`RuntimeError`] on detection failure.
    async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;

    /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
    /// the agent in `cwd` with the prepared context.
    ///
    /// # Errors
    /// [`RuntimeError`] if the invocation cannot be prepared.
    fn prepare_invocation(
        &self,
        profile: &AgentProfile,
        ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError>;
}

/// Une **session programmatique persistante** avec un agent IA (ARCHITECTURE §17.1) :
/// une conversation vivante que l'on pilote en mode structuré et dont on lit la
/// réponse de façon déterministe. Une instance ⇔ un agent IA (invariant « 1 session
/// vivante/agent », porté au niveau *type*).
///
/// Hexagonal : ce trait est **domaine** ; les adapters Claude/Codex (infra) ne
/// fuient aucun détail de CLI à travers lui. Substituable (Liskov) : Claude et
/// Codex offrent les mêmes garanties (flux d'événements → [`ReplyEvent::Final`]
/// déterministe), seul le moteur diffère.
///
/// Calqué sur [`PtyPort`] (consommé comme trait-objet `Arc<dyn AgentSession>`,
/// d'où `#[async_trait]` pour rester object-safe — cf. note d'en-tête du module).
#[async_trait]
pub trait AgentSession: Send + Sync {
    /// L'id de session IdeA (mappe la cellule/agent, comme un [`PtyHandle::session_id`]).
    fn id(&self) -> SessionId;

    /// L'id de conversation **du moteur** (opaque), persisté sur la cellule pour la
    /// reprise (§15.2). `None` tant que le moteur n'en a pas attribué. Permet à
    /// `LeafCell.conversation_id` de rester le pivot de reprise, model-agnostic.
    fn conversation_id(&self) -> Option<String>;

    /// Transmet `prompt` à la session vivante et retourne le **flux** d'événements
    /// du tour (deltas → [`ReplyEvent::Final`]). Rendu incrémental (UI chat) ET
    /// base du rendez-vous synchrone (helper applicatif `send_blocking`).
    ///
    /// # Errors
    /// [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] sur échec de
    /// communication/décodage.
    async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError>;

    /// Comme [`AgentSession::send`], avec un tap optionnel d'événements live produits
    /// pendant le tour avant que le flux batch final soit rendu. L'implémentation par
    /// défaut préserve les sessions/fakes existants : aucun tap, puis [`send`](Self::send).
    ///
    /// # Errors
    /// Identiques à [`AgentSession::send`].
    async fn send_with_tap(
        &self,
        prompt: &str,
        _tap: std::sync::mpsc::Sender<ReplyEvent>,
    ) -> Result<ReplyStream, AgentSessionError> {
        self.send(prompt).await
    }

    /// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent.
    ///
    /// # Errors
    /// [`AgentSessionError::Io`] si l'arrêt échoue.
    async fn shutdown(&self) -> Result<(), AgentSessionError>;
}

/// **Factory** sélectionnée par le profil (ARCHITECTURE §17.1) : crée/reprend une
/// [`AgentSession`] pour un agent IA. C'est elle qui sait *quel adapter* instancier
/// (Claude/Codex) selon `profile.structured_adapter` (§17.3). Open/Closed : ajouter
/// un moteur structuré = ajouter un adapter + une variante de registre, sans
/// toucher au cœur.
#[async_trait]
pub trait AgentSessionFactory: Send + Sync {
    /// Vrai si cette factory sait piloter `profile` en mode structuré (sert au menu
    /// de sélection §17.6 : ne proposer que les profils supportés).
    fn supports(&self, profile: &AgentProfile) -> bool;

    /// Démarre une session structurée pour `profile` dans `cwd` (run dir isolé
    /// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
    /// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
    ///
    /// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà
    /// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour
    /// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution
    /// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan
    /// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ;
    /// l'enforcer concret reste côté infra (injecté par instance dans la fabrique).
    ///
    /// # Errors
    /// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
    /// structuré ne peut s'initialiser.
    async fn start(
        &self,
        profile: &AgentProfile,
        ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
        requester: Option<&str>,
        env: &[(String, String)],
        sandbox: Option<&crate::sandbox::SandboxPlan>,
    ) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
}

/// Open and drive pseudo-terminals.
#[async_trait]
pub trait PtyPort: Send + Sync {
    /// Spawns a PTY running `spec` at the given `size`.
    ///
    /// # Errors
    /// [`PtyError`] on failure.
    async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>;

    /// Writes bytes to the PTY.
    ///
    /// # Errors
    /// [`PtyError`] on failure.
    fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError>;

    /// Resizes the PTY.
    ///
    /// # Errors
    /// [`PtyError`] on failure.
    fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError>;

    /// Subscribes to the PTY's byte output stream.
    ///
    /// Re-subscribable: each call returns a fresh stream that receives every
    /// chunk produced **from now on**. Combined with [`scrollback`](Self::scrollback)
    /// this lets the presentation layer *re-attach* a view to a still-living PTY
    /// after a navigation/layout change tore the previous view down — without
    /// re-spawning the process.
    ///
    /// # Errors
    /// [`PtyError`] if the handle is unknown.
    fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;

    /// Returns the recent output retained for the session (a bounded scrollback
    /// ring buffer, the most recent bytes). Used to repaint a view that
    /// re-attaches to a live PTY so the terminal isn't blank.
    ///
    /// # Errors
    /// [`PtyError`] if the handle is unknown.
    fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError>;

    /// Waits for the PTY process to finish naturally and returns its exit status.
    ///
    /// Idempotent while the session remains registered: the first waiter records
    /// the process status, and subsequent calls return that memorized status.
    ///
    /// # Errors
    /// [`PtyError`] if the handle is unknown.
    async fn wait(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>;

    /// Non-blocking process status check.
    ///
    /// Returns `Ok(None)` while the process is still alive and `Ok(Some(status))`
    /// once it has exited. Like [`wait`](Self::wait), the observed exit status is
    /// memorized until the session is purged.
    ///
    /// # Errors
    /// [`PtyError`] if the handle is unknown.
    fn try_wait(&self, handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError>;

    /// Kills the PTY's process, returning its exit status.
    ///
    /// Idempotent while the session remains registered: if the process has
    /// already exited, returns the memorized status. Implementations may purge
    /// the session as part of kill/cleanup; after purge, calls return
    /// [`PtyError::NotFound`].
    ///
    /// # Errors
    /// [`PtyError`] on failure.
    async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>;
}

/// Run a non-interactive process and capture its output.
#[async_trait]
pub trait ProcessSpawner: Send + Sync {
    /// Runs `spec` to completion.
    ///
    /// # Errors
    /// [`ProcessError`] on failure.
    async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
}

/// Probe readiness of an OpenAI-compatible model server.
#[async_trait]
pub trait ModelServerProbe: Send + Sync {
    /// Probes `endpoint` and returns the observed status.
    ///
    /// # Errors
    /// [`ModelServerError`] on unexpected probe failure. An unreachable server
    /// should normally return [`ModelServerStatus::Unreachable`].
    async fn probe(
        &self,
        endpoint: &ModelServerEndpoint,
    ) -> Result<ModelServerStatus, ModelServerError>;
}

/// Progress observed while resolving/downloading a model artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelArtifactProgress {
    /// Downloaded bytes when known.
    pub downloaded_bytes: Option<u64>,
    /// Total bytes when known.
    pub total_bytes: Option<u64>,
    /// Source being resolved, usually the Hugging Face repo reference.
    pub source: Option<String>,
}

/// Resolved local model artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelArtifactResolution {
    /// Absolute local path to the resolved `.gguf` file.
    pub path: ModelPath,
    /// `true` when the artifact was already available in cache.
    pub cache_hit: bool,
}

/// Cooperative cancellation token for model artifact resolution.
#[derive(Debug, Clone, Default)]
pub struct ModelArtifactCancel {
    cancelled: Arc<AtomicBool>,
}

impl ModelArtifactCancel {
    /// Creates a non-cancelled token.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Requests cancellation.
    pub fn cancel(&self) {
        self.cancelled.store(true, Ordering::SeqCst);
    }

    /// Returns whether cancellation was requested.
    #[must_use]
    pub fn is_cancelled(&self) -> bool {
        self.cancelled.load(Ordering::SeqCst)
    }
}

/// Resolves or downloads a model artifact before starting a model server.
#[async_trait]
pub trait ModelArtifactDownloader: Send + Sync {
    /// Resolves a Hugging Face model to a local artifact path.
    ///
    /// # Errors
    /// [`ModelServerError`] when resolution/download fails or is cancelled.
    async fn resolve_hf_model(
        &self,
        repo: &HfModelRef,
        progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
        cancel: ModelArtifactCancel,
    ) -> Result<ModelArtifactResolution, ModelServerError>;
}

/// Manages local long-lived child processes.
#[async_trait]
pub trait ManagedProcess: Send + Sync {
    /// Spawns a process from an argv-structured spec.
    ///
    /// # Errors
    /// [`ModelServerError`] on spawn failure.
    async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;

    /// Kills a process.
    ///
    /// # Errors
    /// [`ModelServerError`] on kill failure.
    async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;

    /// Returns the status of a process.
    ///
    /// # Errors
    /// [`ModelServerError`] on status failure.
    async fn status(
        &self,
        handle: &ManagedProcessHandle,
    ) -> Result<ProcessStatus, ModelServerError>;
}

/// Builds the argv-structured spawn spec for a local model server.
pub trait ModelServerRuntime: Send + Sync {
    /// Builds the pure argv contract for previewing or spawning a local server.
    ///
    /// # Errors
    /// [`ModelServerError`] if the config cannot be launched.
    fn build_argv(
        &self,
        config: &LocalModelServerConfig,
    ) -> Result<ModelServerArgv, ModelServerError>;

    /// Builds a spawn spec from a validated local-server config.
    ///
    /// # Errors
    /// [`ModelServerError`] if the config cannot be launched.
    fn build_spawn_spec(
        &self,
        config: &LocalModelServerConfig,
    ) -> Result<SpawnSpec, ModelServerError>;
}

/// Pure argv contract for a local model server invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServerArgv {
    /// Executable command.
    pub command: String,
    /// Arguments without shell parsing.
    pub args: Vec<String>,
}

/// Persists local model-server configuration in the global IdeA store.
#[async_trait]
pub trait ModelServerRegistry: Send + Sync {
    /// Returns one server config by id.
    ///
    /// # Errors
    /// [`ModelServerError`] on store failure.
    async fn get(
        &self,
        id: &LocalModelServerId,
    ) -> Result<Option<LocalModelServerConfig>, ModelServerError>;

    /// Lists all server configs.
    ///
    /// # Errors
    /// [`ModelServerError`] on store failure.
    async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;

    /// Saves one server config.
    ///
    /// # Errors
    /// [`ModelServerError`] on store failure.
    async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;

    /// Deletes one server config.
    ///
    /// # Errors
    /// [`ModelServerError`] on store failure.
    async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
}

/// Location-neutral filesystem access.
#[async_trait]
pub trait FileSystem: Send + Sync {
    /// Reads a file.
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError>;

    /// Writes a file (creating or truncating).
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError>;

    /// Returns whether the path exists.
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;

    /// Removes a single file. A **missing** file is treated as success (idempotent
    /// delete), so this is safe to call best-effort.
    ///
    /// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test
    /// doubles keep compiling unchanged; the real adapter overrides it with an
    /// actual delete. Callers that rely on the file actually being gone must use an
    /// adapter that overrides this (the local FS does).
    ///
    /// # Errors
    /// [`FsError`] on a failure other than not-found.
    async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
        let _ = path;
        Ok(())
    }

    /// Creates a directory and all missing parents.
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError>;

    /// Lists the entries of a directory.
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError>;

    /// Creates a symbolic link `dst` pointing at `src`.
    ///
    /// # Errors
    /// [`FsError`] on failure.
    async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>;
}

/// Strategy abstracting *where* execution happens (local / SSH / WSL). Acts as a
/// factory for the location-appropriate fine-grained ports.
#[async_trait]
pub trait RemoteHost: Send + Sync {
    /// The kind of this host.
    fn kind(&self) -> RemoteKind;

    /// Establishes the connection (no-op for local).
    ///
    /// # Errors
    /// [`RemoteError`] on failure.
    async fn connect(&self) -> Result<(), RemoteError>;

    /// Returns the filesystem port for this host.
    fn file_system(&self) -> Arc<dyn FileSystem>;

    /// Returns the process spawner for this host.
    fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;

    /// Returns the PTY port for this host.
    fn pty(&self) -> Arc<dyn PtyPort>;
}

/// CRUD + versioning for agent templates in the global IDE store.
#[async_trait]
pub trait TemplateStore: Send + Sync {
    /// Lists all templates.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError>;

    /// Gets a template by id.
    ///
    /// # Errors
    /// [`StoreError::NotFound`] if absent.
    async fn get(&self, id: crate::ids::TemplateId) -> Result<AgentTemplate, StoreError>;

    /// Saves (creates or replaces) a template.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError>;

    /// Deletes a template.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
}

/// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE
/// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE
/// §14.2 and L12.
///
/// The two scopes are **isolated**: a skill saved as `Project` never surfaces in
/// a `Global` listing and vice-versa. Each call carries the [`SkillScope`]
/// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter
/// resolves the right backing location.
///
/// `root` identifies the project whose `.ideai/skills/` to use for
/// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which
/// lives in the machine-global IDE store). Passing the root per call — rather
/// than baking it into the adapter — keeps a single store instance correct
/// across every open project (mirroring [`AgentContextStore`]).
#[async_trait]
pub trait SkillStore: Send + Sync {
    /// Lists all skills in `scope` (for `root`'s project when project-scoped).
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError>;

    /// Gets a skill by id within `scope`.
    ///
    /// # Errors
    /// [`StoreError::NotFound`] if absent in that scope.
    async fn get(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
        id: crate::ids::SkillId,
    ) -> Result<Skill, StoreError>;

    /// Saves (creates or replaces by id) a skill in its own [`Skill::scope`].
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>;

    /// Deletes a skill by id within `scope`.
    ///
    /// # Errors
    /// [`StoreError::NotFound`] if absent in that scope.
    async fn delete(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
        id: crate::ids::SkillId,
    ) -> Result<(), StoreError>;
}

/// Persistence for the agent **live-state** snapshot (programme live-state).
///
/// Mirrors the other stores ([`SkillStore`], [`MemoryStore`]): a driven port the
/// infrastructure implements. The contract is **keyed last-writer-wins**, never
/// append — [`upsert`](LiveStateStore::upsert) replaces the row for an agent and
/// [`prune`](LiveStateStore::prune) applies the TTL + cardinality bound, matching
/// [`LiveState`](crate::live_state::LiveState)'s pure semantics.
#[async_trait]
pub trait LiveStateStore: Send + Sync {
    /// Loads the current live-state snapshot.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn load(&self) -> Result<crate::live_state::LiveState, StoreError>;

    /// Inserts or replaces (keyed by `agent_id`) a single live-state row.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn upsert(&self, entry: crate::live_state::LiveEntry) -> Result<(), StoreError>;

    /// Drops rows older than `ttl_ms` relative to `now_ms`, then bounds the set
    /// to `max_n`, keeping the most recently updated rows.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError>;
}

/// CRUD for project [`Memory`] notes — the `.md` knowledge base under
/// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the
/// aggregated `MEMORY.md` index is derived and kept in sync on every write.
///
/// `root` identifies the project whose `.ideai/memory/` to use; it is supplied
/// **per call** (mirroring [`SkillStore`]) so a single store instance serves
/// every open project.
#[async_trait]
pub trait MemoryStore: Send + Sync {
    /// Lists all memory notes for `root`'s project.
    ///
    /// # Errors
    /// [`MemoryError`] on failure (e.g. a malformed note's frontmatter).
    async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError>;

    /// Gets a memory note by slug.
    ///
    /// # Errors
    /// [`MemoryError::NotFound`] if absent; [`MemoryError::Frontmatter`] if its
    /// frontmatter is malformed.
    async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError>;

    /// Saves (creates or replaces by slug) a note: writes `<slug>.md` and upserts
    /// its line in `MEMORY.md` idempotently.
    ///
    /// # Errors
    /// [`MemoryError`] on failure.
    async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError>;

    /// Deletes a note by slug, removing its line from `MEMORY.md`.
    ///
    /// # Errors
    /// [`MemoryError::NotFound`] if absent.
    async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError>;

    /// Reads the aggregated `MEMORY.md` index as structured entries (empty if the
    /// index does not exist yet).
    ///
    /// # Errors
    /// [`MemoryError`] on an I/O failure.
    async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError>;

    /// Resolves the `[[slug]]` links emanating from `slug`'s note, **ignoring
    /// broken links** (targets that do not resolve to an existing note).
    ///
    /// # Errors
    /// [`MemoryError::NotFound`] if `slug` itself does not exist.
    async fn resolve_links(
        &self,
        root: &ProjectPath,
        slug: &MemorySlug,
    ) -> Result<Vec<MemoryLink>, MemoryError>;
}

/// A recall request: the query text plus the token budget bounding the result
/// (LOT B, étage 1). `text` is typically the agent's current context; the naïve
/// adapter ignores it, but a semantic [`MemoryRecall`] (LOT C) ranks against it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryQuery {
    /// The recall query (often the agent's current working context).
    pub text: String,
    /// Approximate token budget the returned entries must fit within. A budget of
    /// `0` yields an empty result.
    pub token_budget: usize,
}

/// Adaptive recall of the most relevant subset of a project's memory index for a
/// query, bounded by a token budget (LOT B, étage 1).
///
/// Contract (best-effort, never blocking):
/// - an empty or absent memory yields an empty list, **never** an error;
/// - a `token_budget` of `0` yields an empty list;
/// - the naïve adapter ignores semantic relevance and returns the index entries
///   in order, truncated to fit the budget.
///
/// **Liskov**: every implementation (`NaiveMemoryRecall`, the future
/// `VectorMemoryRecall`) is substitutable — same emptiness/budget guarantees, only
/// the relevance strategy differs.
#[async_trait]
pub trait MemoryRecall: Send + Sync {
    /// Returns the entries most relevant to `query` for `root`'s project, capped
    /// at `query.token_budget`.
    ///
    /// # Errors
    /// [`MemoryError`] only on an unexpected I/O failure of the underlying store;
    /// an empty or missing memory is **not** an error (returns an empty list).
    async fn recall(
        &self,
        root: &ProjectPath,
        query: &MemoryQuery,
    ) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
}

/// Produces embedding vectors for texts, driven by a declarative
/// [`crate::profile::EmbedderProfile`] (façon §9 — adding an engine is *data*,
/// not code: Open/Closed). The étage-2 vector recall (LOT C) composes this port
/// to rank memory notes semantically.
///
/// Contract:
/// - [`embed`](Self::embed) returns **one vector per input text**, in the same
///   order, each of length [`dimension`](Self::dimension);
/// - it is **best-effort from the caller's standpoint**: any failure is an
///   [`EmbedderError`], on which a composing [`MemoryRecall`] degrades to the
///   naïve recall — it must never bubble up as a hard recall error;
/// - implementations are **substitutable** (Liskov): a deterministic test
///   embedder and a real ONNX/HTTP one differ only in vector quality, not in the
///   shape of their guarantees.
#[async_trait]
pub trait Embedder: Send + Sync {
    /// Stable identifier of this embedder (e.g. `"local-onnx-minilm"`).
    fn id(&self) -> &str;

    /// Embeds each text into a `dimension()`-length vector, preserving order.
    ///
    /// # Errors
    /// - [`EmbedderError::Unavailable`] if the engine is not installed/reachable,
    /// - [`EmbedderError::Unsupported`] if the strategy is not implemented yet,
    /// - [`EmbedderError::Io`] on an I/O failure.
    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;

    /// The length of every vector produced by [`embed`](Self::embed).
    fn dimension(&self) -> usize;
}

/// Persistence of the known-projects registry and the workspace.
#[async_trait]
pub trait ProjectStore: Send + Sync {
    /// Lists all known projects.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError>;

    /// Loads a single project.
    ///
    /// # Errors
    /// [`StoreError::NotFound`] if absent.
    async fn load_project(&self, id: crate::ids::ProjectId) -> Result<Project, StoreError>;

    /// Saves (creates or replaces) a project.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save_project(&self, project: &Project) -> Result<(), StoreError>;

    /// Saves the whole workspace (windows/tabs/layouts).
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save_workspace(&self, workspace: &crate::layout::Workspace) -> Result<(), StoreError>;

    /// Loads the persisted workspace.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
}

/// Machine-local persistence of open OS window state.
#[async_trait]
pub trait WindowStateStore: Send + Sync {
    /// Saves the latest open-window snapshot.
    ///
    /// # Errors
    /// [`StoreError`] on persistence failure.
    async fn save_window_state(
        &self,
        snapshot: &crate::layout::WindowStateSnapshot,
    ) -> Result<(), StoreError>;

    /// Loads the latest open-window snapshot.
    ///
    /// # Errors
    /// [`StoreError`] on unexpected I/O or deserialisation failure. A missing
    /// snapshot is represented as an empty default snapshot.
    async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
}

/// CRUD for the configured [`AgentProfile`]s in the global IDE store
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
/// single generic [`AgentRuntime`] adapter (Open/Closed).
#[async_trait]
pub trait ProfileStore: Send + Sync {
    /// Lists all configured profiles.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError>;

    /// Saves (creates or replaces by id) a profile.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError>;

    /// Deletes a profile by id.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn delete(&self, id: crate::ids::ProfileId) -> Result<(), StoreError>;

    /// Whether the profiles store has been initialised yet — used to detect the
    /// first run of the IDE (no `profiles.json` ⇒ first run).
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn is_configured(&self) -> Result<bool, StoreError>;

    /// Persists an (possibly empty) profiles store, recording that the first run
    /// is complete even when the user kept no profile.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn mark_configured(&self) -> Result<(), StoreError>;
}

/// CRUD for the declarative [`EmbedderProfile`]s in the global IDE store
/// (`embedder.json`, LOT C, §14.5.3). Mirrors [`ProfileStore`] for the embedding
/// engines: adding an engine is *data* the user configures, not code.
///
/// There is **no** `is_configured`/`mark_configured`: the default embedder posture
/// is `none` **by absence of the file** (an empty/missing `embedder.json` ⇒ recall
/// stays the dependency-free naïve étage 1). A configured embedder takes effect at
/// the **next IDE start** (the composition root freezes the recall for the session).
#[async_trait]
pub trait EmbedderProfileStore: Send + Sync {
    /// Lists all configured embedder profiles (empty when none configured).
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError>;

    /// Saves (creates or replaces by id) an embedder profile.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError>;

    /// Deletes an embedder profile by id.
    ///
    /// # Errors
    /// [`StoreError::NotFound`] if no profile carries that id.
    async fn delete(&self, id: &str) -> Result<(), StoreError>;
}

/// Best-effort snapshot of the embedding *environment*: which local engines are
/// already installed/cached on this machine. Drives the "configure an embedder?"
/// UI (C2/C3) so it can detect an existing engine before suggesting a download.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EmbedderEnvReport {
    /// Whether an Ollama-style local embedding server was detected (best-effort;
    /// always `false` in a build without the HTTP capability).
    pub ollama_detected: bool,
    /// Ids of the recommended ONNX models already present in the local cache.
    pub onnx_cached_models: Vec<String>,
}

/// Probes the local embedding environment (installed/cached engines). **Best-effort
/// by contract**: [`inspect`](Self::inspect) never errors — an unreachable host or
/// an unreadable cache simply yields a report with the corresponding fields unset.
#[async_trait]
pub trait EmbedderEnvInspector: Send + Sync {
    /// Returns a best-effort snapshot of the local embedding environment.
    async fn inspect(&self) -> EmbedderEnvReport;
}

/// The persisted user response to the "configure an embedder?" suggestion
/// (ARCHITECTURE §14.5.5, LOT C3), stored per project under
/// `.ideai/memory/.embedder-prompt.json`. Absence of the file ⇒ never answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbedderPromptDismissal {
    /// "Plus tard" — re-proposable on a future session (not this one).
    Later,
    /// "Ne plus demander" — never publish the suggestion again (persistent).
    Never,
}

/// Persistence of the per-project embedder-suggestion state
/// (`.ideai/memory/.embedder-prompt.json`, LOT C3). A fine-grained port (Interface
/// Segregation) so the suggestion use cases depend on this alone, not on a wider
/// store. **Best-effort reads**: a missing/malformed file is *not* an error — it
/// reads as "never answered" ([`None`]).
#[async_trait]
pub trait EmbedderPromptStore: Send + Sync {
    /// Reads the recorded dismissal for `root`'s project, or `None` when the user
    /// has never answered (no file / unreadable / malformed).
    ///
    /// # Errors
    /// [`StoreError`] only on an unexpected I/O failure the adapter chooses to
    /// surface; a plain missing file degrades to `Ok(None)`.
    async fn read(&self, root: &ProjectPath)
        -> Result<Option<EmbedderPromptDismissal>, StoreError>;

    /// Records the user's dismissal choice for `root`'s project.
    ///
    /// # Errors
    /// [`StoreError`] on a persistence failure.
    async fn write(
        &self,
        root: &ProjectPath,
        dismissal: EmbedderPromptDismissal,
    ) -> Result<(), StoreError>;
}

/// Persists paired devices and their session-token hashes.
#[async_trait]
pub trait DeviceSessionStore: Send + Sync {
    /// Loads all paired devices. A missing store is represented as an empty list.
    async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError>;

    /// Replaces the persisted device list atomically enough for the filesystem
    /// adapter's single-process use case.
    async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError>;

    /// Removes one device by id.
    async fn remove_device(&self, device_id: DeviceId) -> Result<bool, StoreError> {
        let mut devices = self.load_devices().await?;
        let before = devices.len();
        devices.retain(|device| device.device_id != device_id);
        if devices.len() != before {
            self.save_devices(&devices).await?;
            return Ok(true);
        }
        Ok(false)
    }

    /// Authenticates a session token against persisted token hashes.
    async fn authenticate_token(
        &self,
        token_bytes: &[u8],
    ) -> Result<Option<AuthenticatedDevice>, StoreError> {
        for device in self.load_devices().await? {
            if device.session_token_hash.verify_token_bytes(token_bytes) {
                return Ok(Some(AuthenticatedDevice {
                    device_id: device.device_id,
                    token_hash: device.session_token_hash,
                }));
            }
        }
        Ok(None)
    }
}

/// Reads/writes agent `.md` contexts and the project manifest, within a project.
#[async_trait]
pub trait AgentContextStore: Send + Sync {
    /// Reads an agent's context.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn read_context(
        &self,
        project: &Project,
        agent: &AgentId,
    ) -> Result<MarkdownDoc, StoreError>;

    /// Writes an agent's context.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn write_context(
        &self,
        project: &Project,
        agent: &AgentId,
        md: &MarkdownDoc,
    ) -> Result<(), StoreError>;

    /// Loads the project manifest.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;

    /// Saves the project manifest.
    ///
    /// # Errors
    /// [`StoreError`] on failure.
    async fn save_manifest(
        &self,
        project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError>;
}

/// Reads/writes a project's `.ideai/permissions.json`.
#[async_trait]
pub trait PermissionStore: Send + Sync {
    /// Loads the project's permission document. Missing file returns the default
    /// empty document.
    ///
    /// # Errors
    /// [`StoreError`] on I/O or deserialisation failure.
    async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError>;

    /// Saves the project's permission document.
    ///
    /// # Errors
    /// [`StoreError`] on I/O or serialisation failure.
    async fn save_permissions(
        &self,
        project: &Project,
        permissions: &ProjectPermissions,
    ) -> Result<(), StoreError>;
}

/// Reads/writes a project's `.ideai/mcp-tool-permissions.json`.
///
/// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP
/// application tools, not filesystem/process/sandbox capabilities.
#[async_trait]
pub trait McpToolPermissionStore: Send + Sync {
    /// Loads the project's MCP tool permission document. Missing file returns the
    /// default empty document.
    ///
    /// # Errors
    /// [`StoreError`] on I/O, deserialisation, or validation failure.
    async fn load_mcp_tool_permissions(
        &self,
        project: &Project,
    ) -> Result<ProjectMcpToolPermissions, StoreError>;

    /// Saves the project's MCP tool permission document.
    ///
    /// # Errors
    /// [`StoreError`] on I/O, serialisation, or validation failure.
    async fn save_mcp_tool_permissions(
        &self,
        project: &Project,
        permissions: &ProjectMcpToolPermissions,
    ) -> Result<(), StoreError>;
}

/// Persistence port for first-class background tasks.
#[async_trait]
pub trait BackgroundTaskStore: Send + Sync {
    /// Creates a new task.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] if the task already exists or cannot be stored.
    async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError>;

    /// Gets a task by id.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] on store failure.
    async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError>;

    /// Saves a task.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] on store failure.
    async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError>;

    /// Lists non-terminal tasks owned by an agent.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] on store failure.
    async fn list_open_for_agent(
        &self,
        agent_id: AgentId,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError>;

    /// Lists terminal tasks whose completion has not been delivered yet.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] on store failure.
    async fn list_undelivered_completions(
        &self,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError>;

    /// Marks a completion as delivered.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] on store failure.
    async fn mark_completion_delivered(
        &self,
        task_id: TaskId,
    ) -> Result<(), BackgroundTaskPortError>;
}

/// Execution port for first-class background tasks.
#[async_trait]
pub trait BackgroundTaskRunner: Send + Sync {
    /// Spawns a background task.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] when the task cannot be spawned.
    async fn spawn(
        &self,
        spec: BackgroundTaskSpec,
    ) -> Result<BackgroundTaskHandle, BackgroundTaskPortError>;

    /// Cancels a running task.
    ///
    /// # Errors
    /// [`BackgroundTaskPortError`] when cancellation fails.
    async fn cancel(&self, task_id: TaskId) -> Result<(), BackgroundTaskPortError>;

    /// Subscribes to terminal completions.
    fn subscribe_completions(&self) -> BackgroundCompletionStream;
}

/// Wakes an agent to process one queued system/inbox item.
#[async_trait]
pub trait AgentWakePort: Send + Sync {
    /// Wakes `agent` in `project` for `reason`, if the agent is idle.
    ///
    /// Implementations must drain at most one inbox item and must not start a
    /// concurrent turn when the agent is already busy.
    ///
    /// # Errors
    /// [`WakeError`] when the session/store operation fails or the queued item is
    /// inconsistent with the requested wake.
    async fn wake_agent(
        &self,
        project: &Project,
        agent: AgentId,
        reason: WakeReason,
    ) -> Result<(), WakeError>;
}

/// Allocates monotonic per-project issue numbers.
#[async_trait]
pub trait IssueNumberAllocator: Send + Sync {
    /// Allocates the next number for `root`.
    ///
    /// Implementations must never reuse an allocated number, even if a later
    /// issue creation step fails.
    ///
    /// # Errors
    /// [`IssueStoreError`] on persistence/lock failure.
    async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError>;
}

/// Persistence port for project-scoped issues.
#[async_trait]
pub trait IssueStore: Send + Sync {
    /// Creates a new issue.
    ///
    /// # Errors
    /// [`IssueStoreError`] when the issue already exists or cannot be stored.
    async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError>;

    /// Reads an issue by `#N` reference.
    ///
    /// # Errors
    /// [`IssueStoreError::NotFound`] when absent.
    async fn get_by_ref(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
    ) -> Result<Issue, IssueStoreError>;

    /// Lists issue index entries matching `filter`.
    ///
    /// # Errors
    /// [`IssueStoreError`] on persistence failure.
    async fn list(
        &self,
        root: &ProjectPath,
        filter: IssueListFilter,
    ) -> Result<Vec<IssueIndexEntry>, IssueStoreError>;

    /// Replaces an issue after checking its expected version.
    ///
    /// # Errors
    /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
    async fn update(
        &self,
        root: &ProjectPath,
        issue: &Issue,
        expected_version: IssueVersion,
    ) -> Result<(), IssueStoreError>;

    /// Deletes an issue by `#N` reference.
    ///
    /// # Errors
    /// [`IssueStoreError::NotFound`] when absent.
    async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError>;

    /// Reads only the issue carnet projection.
    ///
    /// # Errors
    /// [`IssueStoreError::NotFound`] when absent.
    async fn read_carnet(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
    ) -> Result<IssueCarnet, IssueStoreError>;

    /// Replaces an issue carnet and increments the issue version.
    ///
    /// # Errors
    /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
    async fn write_carnet(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
        carnet: MarkdownDoc,
        actor: crate::issue::IssueActor,
        now_ms: u64,
        expected_version: IssueVersion,
    ) -> Result<IssueCarnet, IssueStoreError>;
}

/// Persistence port for project-scoped sprints.
#[async_trait]
pub trait SprintStore: Send + Sync {
    /// Creates a new sprint.
    ///
    /// # Errors
    /// [`SprintStoreError`] when the sprint already exists or cannot be stored.
    async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError>;

    /// Reads a sprint by stable id.
    ///
    /// # Errors
    /// [`SprintStoreError::NotFound`] when absent.
    async fn get(
        &self,
        root: &ProjectPath,
        sprint_id: SprintId,
    ) -> Result<Sprint, SprintStoreError>;

    /// Lists sprint index entries.
    ///
    /// # Errors
    /// [`SprintStoreError`] on persistence failure.
    async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError>;

    /// Replaces a sprint after checking its expected version.
    ///
    /// # Errors
    /// [`SprintStoreError::VersionConflict`] on optimistic-concurrency conflict.
    async fn update(
        &self,
        root: &ProjectPath,
        sprint: &Sprint,
        expected_version: SprintVersion,
    ) -> Result<(), SprintStoreError>;

    /// Deletes a sprint by id.
    ///
    /// # Errors
    /// [`SprintStoreError::NotFound`] when absent.
    async fn delete(&self, root: &ProjectPath, sprint_id: SprintId)
        -> Result<(), SprintStoreError>;
}

/// Git operations for a project. Named `GitPort` to avoid clashing with the
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]
pub trait GitPort: Send + Sync {
    /// Initialises a repository at the root.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn init(&self, root: &ProjectPath) -> Result<(), GitError>;

    /// Returns the status of changed paths.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn status(&self, root: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError>;

    /// Stages a path.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;

    /// Unstages a path.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;

    /// Creates a commit with the given message.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn commit(&self, root: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError>;

    /// Lists branches.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError>;

    /// Returns the current branch.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, GitError>;

    /// Checks out a branch.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError>;

    /// Returns the recent commit log.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError>;

    /// Returns the commit graph (all local branches, topological + time sort).
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn log_graph(
        &self,
        root: &ProjectPath,
        limit: usize,
    ) -> Result<Vec<GraphCommit>, GitError>;

    /// Pulls from the default remote.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn pull(&self, root: &ProjectPath) -> Result<(), GitError>;

    /// Pushes to the default remote.
    ///
    /// # Errors
    /// [`GitError`] on failure.
    async fn push(&self, root: &ProjectPath) -> Result<(), GitError>;
}

/// **Optional** capability to read enriched details from a CLI's conversation
/// transcript (CONTEXT §T6).
///
/// This port is optional *by construction*: it backs a best-effort resume popup
/// and is never wired as a hard dependency of the launch/resume flow. A caller
/// (the future `InspectConversation` use case, §T7) holds a
/// `Vec<Arc<dyn SessionInspector>>` and routes a profile to the first inspector
/// whose [`supports`](Self::supports) returns `true`; if none matches, or the
/// call errors, the resume proceeds with no enriched details.
///
/// All Claude/Codex/Gemini transcript shapes stay **inside the adapter**: no
/// CLI-specific type ever crosses this boundary — only [`ConversationDetails`].
#[async_trait]
pub trait SessionInspector: Send + Sync {
    /// Returns whether this inspector knows how to read transcripts for the
    /// given profile (e.g. by recognising its context-injection convention).
    fn supports(&self, profile: &AgentProfile) -> bool;

    /// Reads best-effort [`ConversationDetails`] for `conversation_id` whose
    /// agent runs in `cwd`.
    ///
    /// # Errors
    /// [`InspectError::NotFound`] when no transcript exists for the conversation;
    /// [`InspectError::Read`] on an I/O / decoding failure. Malformed *lines*
    /// inside an otherwise-readable transcript are skipped, not surfaced.
    async fn details(
        &self,
        profile: &AgentProfile,
        conversation_id: &str,
        cwd: &ProjectPath,
    ) -> Result<ConversationDetails, InspectError>;
}

/// Callback fired **once per detected turn end** by a [`TurnWatcher`].
///
/// Invoked from the watcher's own polling task (never while holding a lock), so the
/// composition root can safely route it to
/// [`crate::input::InputMediator::turn_ended`] for the given agent.
pub type OnTurnEnd = Arc<dyn Fn(AgentId) + Send + Sync>;

/// Opaque handle to a live [`TurnWatcher::watch`]. **Dropping it stops the watch**
/// (the adapter's polling task must observe the drop and cease firing, idempotently).
pub trait TurnWatchHandle: Send + Sync {}

/// Observes an agent's **end-of-turn** signal from its on-disk transcript, replacing
/// the dead PTY prompt-ready sniff as the turn-end authority (rendez-vous no-reply
/// backstop). Model-specific: an adapter watches the CLI's transcript shape (e.g. the
/// Claude `turn_duration` record) and fires [`OnTurnEnd`] each time a turn completes.
///
/// Pure port (no async, no I/O in the trait): the adapter owns its polling task. The
/// composition root arms one watch per **live agent session** (keyed on `cwd`, the
/// agent's isolated run dir) and drops the handle on close/exit.
pub trait TurnWatcher: Send + Sync {
    /// Whether this watcher knows how to read turn ends for the given profile
    /// (mirrors [`SessionInspector::supports`] — e.g. recognising `CLAUDE.md`).
    fn supports(&self, profile: &AgentProfile) -> bool;

    /// Arms a watch over `agent`'s transcript under `cwd` (its run dir). The adapter
    /// captures a **baseline** of turn ends already present at arm time and fires
    /// `on_turn_end(agent)` only on increments **above** that baseline (so a
    /// pre-existing transcript — relaunch / background wake — never triggers a phantom
    /// turn end). `conversation_id` is an optional diagnostic label only — it is **not**
    /// the file key (the transcript stem is the engine session id, unknown at cold
    /// start). Returns a [`TurnWatchHandle`]; dropping it stops the watch.
    fn watch(
        &self,
        agent: AgentId,
        conversation_id: Option<String>,
        cwd: ProjectPath,
        on_turn_end: OnTurnEnd,
    ) -> Box<dyn TurnWatchHandle>;
}

/// Publish/subscribe domain events. Synchronous, in-process.
pub trait EventBus: Send + Sync {
    /// Publishes an event.
    fn publish(&self, event: DomainEvent);

    /// Subscribes to the event stream.
    fn subscribe(&self) -> EventStream;
}

/// Provides the current time (epoch milliseconds), abstracted for determinism.
pub trait Clock: Send + Sync {
    /// Returns "now" as epoch milliseconds.
    fn now_millis(&self) -> i64;
}

/// Generates fresh UUIDs, abstracted for determinism in tests.
pub trait IdGenerator: Send + Sync {
    /// Returns a fresh UUID.
    fn new_uuid(&self) -> uuid::Uuid;
}

/// Minuterie **one-shot annulable** (ARCHITECTURE §21.4) — le **seul** port neuf de
/// la feature « limites de session ». Là où [`Clock`] dit *quelle heure il est*, ce
/// port *réveille* à une échéance absolue donnée.
///
/// # Interface Segregation (§21.8-I)
/// Volontairement minimal — `arm` / `cancel`, rien d'autre — et **distinct** de
/// [`Clock`] : un consommateur qui n'a besoin que de « réveille-moi à T » ne dépend
/// pas d'un store ni d'une horloge.
///
/// # Synchrone (pas d'`async_trait`)
/// Comme [`Clock`], [`IdGenerator`] et [`EventBus`], ce port est **non bloquant** :
/// `arm` ne fait qu'enregistrer une minuterie (l'attente réelle se passe en tâche de
/// fond dans l'adapter) et `cancel` ne fait qu'annuler — aucun `await` au point
/// d'appel. On le garde donc en `fn` simple, sans payer le boxing d'`async_trait`.
///
/// # Remise de la tâche échue — dispatch par DONNÉE (§14.3, §21.4)
/// Le port **n'exécute jamais** la reprise (aucune dépendance vers l'application).
/// À l'échéance, l'adapter **pousse la [`ScheduledTask`] (une valeur)** dans un canal
/// fourni à sa construction, que l'application **draine** et exécute — exactement le
/// patron du watcher d'orchestrateur, qui dispatche une requête-donnée vers
/// l'`OrchestratorService`. Aucune closure ne franchit la frontière domaine.
///
/// # État en mémoire (§21.1-3)
/// Aucune persistance : un réveil armé ne survit pas à un redémarrage d'IdeA (le
/// chemin `ListResumableAgents` existant prend alors le relais).
///
/// # Substituabilité (Liskov)
/// Tout adapter (le `TokioScheduler` réel comme un fake de test qui tire à la
/// demande) respecte le même contrat : `arm` rend un id annulable ; `cancel` renvoie
/// `true` ssi il a effectivement désarmé un réveil non encore tiré.
pub trait Scheduler: Send + Sync {
    /// Arme une minuterie one-shot à `deadline_ms` (**époche-millisecondes absolues**,
    /// cohérent avec [`Clock::now_millis`] et `ResumePlan::Scheduled.fire_at_ms`). À
    /// l'échéance, l'adapter remet `task` au drain applicatif (cf. doc du trait).
    /// Renvoie un [`ScheduleId`] annulable.
    ///
    /// `deadline_ms` **≤ now** ⇒ déclenchement **au plus tôt** (immédiat). Le clamp
    /// anti-passé est déjà assuré en amont par `plan_resume` (domaine) ; ce contrat ne
    /// fait que garantir qu'une échéance passée ne « se perd » pas.
    fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;

    /// Annule un réveil armé **non encore tiré**. Idempotent et **sans erreur** :
    /// renvoie `true` s'il a effectivement été désarmé, `false` s'il était inconnu ou
    /// déjà tiré. C'est ce qui sous-tend la « reprise auto **annulable** » (§21.1-4).
    fn cancel(&self, id: ScheduleId) -> bool;
}
