//! AI runtime profile and its context-injection strategy.
//!
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
//! port is parameterised by an [`AgentProfile`].

use serde::{Deserialize, Serialize};

use crate::error::DomainError;
use crate::ids::{LocalModelServerId, ProfileId};
use crate::permission::ProjectorKey;

/// Strategy for injecting an agent's `.md` context into the launched CLI.
///
/// Invariants:
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
/// - `Env.var` is a valid environment-variable identifier,
/// - `Flag.flag` is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum ContextInjection {
    /// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
    ConventionFile {
        /// Relative target file name.
        target: String,
    },
    /// Pass the context file path through a CLI flag.
    Flag {
        /// The flag template (e.g. `--context-file {path}` or `-f`).
        flag: String,
    },
    /// Pipe the Markdown content on stdin.
    Stdin,
    /// Pass the context via an environment variable.
    Env {
        /// Environment variable name.
        var: String,
    },
}

impl ContextInjection {
    /// Validated `ConventionFile` constructor.
    ///
    /// # Errors
    /// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
    /// contains `..`.
    pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
        let target = target.into();
        crate::validation::relative_safe(&target)?;
        Ok(Self::ConventionFile { target })
    }

    /// Validated `Flag` constructor.
    ///
    /// # Errors
    /// Returns [`DomainError::EmptyField`] if `flag` is empty.
    pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
        let flag = flag.into();
        crate::validation::non_empty(&flag, "contextInjection.flag")?;
        Ok(Self::Flag { flag })
    }

    /// `Stdin` constructor (no validation needed).
    #[must_use]
    pub const fn stdin() -> Self {
        Self::Stdin
    }

    /// Validated `Env` constructor.
    ///
    /// # Errors
    /// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
    pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
        let var = var.into();
        crate::validation::valid_env_var(&var)?;
        Ok(Self::Env { var })
    }
}

/// Declares how IdeA assigns and resumes a CLI conversation, without parsing
/// the model's output (universal, declarative — see CONTEXT.md §9).
///
/// Optional on a profile: a profile without a `session` block behaves as today
/// (no resume).
///
/// Invariants:
/// - `resume_flag` is non-empty,
/// - if `assign_flag` is `Some`, it is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionStrategy {
    /// Flag passed on the FIRST launch with a UUID generated by IdeA
    /// (e.g. `"--session-id"`). `None` => degraded mode (resume without id).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub assign_flag: Option<String>,
    /// Resume flag (e.g. `"--resume"` or `"--continue"`).
    pub resume_flag: String,
}

impl SessionStrategy {
    /// Builds a validated session strategy.
    ///
    /// # Errors
    /// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if
    /// `assign_flag` is `Some` but empty.
    pub fn new(
        assign_flag: Option<String>,
        resume_flag: impl Into<String>,
    ) -> Result<Self, DomainError> {
        let resume_flag = resume_flag.into();
        crate::validation::non_empty(&resume_flag, "session.resumeFlag")?;
        if let Some(flag) = &assign_flag {
            crate::validation::non_empty(flag, "session.assignFlag")?;
        }
        Ok(Self {
            assign_flag,
            resume_flag,
        })
    }
}

/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
///
/// Deux seuils optionnels :
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
///   `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
///   (`ReadinessSignal::Stalled`) — détection au lot 2 ;
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
///   (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
///
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LivenessStrategy {
    /// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
    /// `None` ⇒ pas de détection de stagnation (lot 2).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stall_after_ms: Option<u32>,
    /// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
    /// garde-fou par profil (lot 2).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub turn_timeout_ms: Option<u32>,
}

impl LivenessStrategy {
    /// Construit une stratégie de vivacité validée (parse-don't-validate, comme
    /// [`SessionStrategy::new`]).
    ///
    /// # Errors
    /// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
    /// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
    pub const fn new(
        stall_after_ms: Option<u32>,
        turn_timeout_ms: Option<u32>,
    ) -> Result<Self, DomainError> {
        if let Some(0) = stall_after_ms {
            return Err(DomainError::EmptyField {
                field: "liveness.stallAfterMs",
            });
        }
        if let Some(0) = turn_timeout_ms {
            return Err(DomainError::EmptyField {
                field: "liveness.turnTimeoutMs",
            });
        }
        Ok(Self {
            stall_after_ms,
            turn_timeout_ms,
        })
    }
}

/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
///
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif
/// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt
/// serait une simple sous-chaîne littérale, la limite de session a
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée
/// ne franchit la frontière domaine : l'infra émet un
/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée.
///
/// Invariant (garanti par le constructeur) : `pattern` est non vide.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RateLimitPattern {
    /// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la
    /// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile
    /// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2).
    pub pattern: String,
    /// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait
    /// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset
    /// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet
    /// humain). Donnée opaque au domaine.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reset_capture: Option<String>,
    /// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la
    /// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose
    /// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une
    /// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par
    /// défaut. Donnée opaque au domaine.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub time_format: Option<String>,
}

impl RateLimitPattern {
    /// Construit un motif validé (parse-don't-validate, comme
    /// [`SessionStrategy::new`] / [`LivenessStrategy::new`]).
    ///
    /// # Errors
    /// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si
    /// `pattern` est vide.
    pub fn new(
        pattern: impl Into<String>,
        reset_capture: Option<String>,
        time_format: Option<String>,
    ) -> Result<Self, DomainError> {
        let pattern = pattern.into();
        crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?;
        Ok(Self {
            pattern,
            reset_capture,
            time_format,
        })
    }
}

/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
///
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
/// adapter le pilote en mode programmatique. Ajouter un moteur structuré = une
/// variante ici + un adapter infra (`infrastructure/src/session/`), sans toucher
/// au cœur. Un profil **sans** `structured_adapter` reste un profil **TUI/PTY**
/// (terminal brut, comportement historique).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StructuredAdapter {
    /// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK).
    Claude,
    /// Piloté par `CodexExecSession` (`codex exec` structuré).
    Codex,
    /// Piloté par OpenCode en mode process-backed (`opencode run --format json`).
    OpenCode,
    /// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
    OpenAiCompatible,
}

impl StructuredAdapter {
    /// Clé **stable par famille de moteur** (provider), indépendante de l'uuid
    /// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap
    /// d'un profil vers un autre profil de **la même famille** réutilise la même
    /// entrée (resumable partagé), et le fichier reste lisible à l'œil.
    ///
    /// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation
    /// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce
    /// mapping ne doit pas dériver si la représentation serde change).
    #[must_use]
    pub const fn provider_key(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
            Self::OpenCode => "opencode",
            Self::OpenAiCompatible => "openai-compatible",
        }
    }
}

/// Configuration déclarative d'un profil OpenCode process-backed.
///
/// OpenCode reste une CLI locale pilotée par process, distincte de
/// [`StructuredAdapter::OpenAiCompatible`] qui est un client HTTP in-process.
/// IdeA matérialise cette configuration dans un `opencode.json` minimal pour un
/// provider llama.cpp compatible OpenAI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeConfig {
    /// Endpoint `/v1` du serveur llama.cpp compatible OpenAI.
    #[serde(rename = "baseURL")]
    pub base_url: String,
    /// Clé API optionnelle transmise au provider OpenCode.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key: Option<String>,
    /// Nom nu du modèle servi par llama-server, par exemple `qwen3-coder-30b`.
    pub model: String,
    /// Active le mode reasoning côté modèle OpenCode. Défaut effectif : `true`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<bool>,
    /// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attachment: Option<bool>,
    /// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA.
    ///
    /// `None` garde le comportement existant : profil OpenCode manuel/externe.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub local_model_server_id: Option<LocalModelServerId>,
}

impl OpenCodeConfig {
    /// Construit une configuration OpenCode validée.
    ///
    /// # Errors
    /// Renvoie une erreur domaine si `base_url` n'est pas HTTP(S) ou si
    /// `base_url`/`model` est vide.
    pub fn new(
        base_url: impl Into<String>,
        api_key: Option<String>,
        model: impl Into<String>,
        reasoning: Option<bool>,
        attachment: Option<bool>,
    ) -> Result<Self, DomainError> {
        let base_url = base_url.into();
        let model = model.into();
        crate::validation::non_empty(&base_url, "opencode.baseURL")?;
        if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
            return Err(DomainError::Invariant(
                "opencode.baseURL must start with http:// or https://".to_owned(),
            ));
        }
        crate::validation::non_empty(&model, "opencode.model")?;
        Ok(Self {
            base_url,
            api_key,
            model,
            reasoning,
            attachment,
            local_model_server_id: None,
        })
    }

    /// Builder : associe cette configuration à un serveur local managé.
    #[must_use]
    pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self {
        self.local_model_server_id = Some(id);
        self
    }

    /// Valeur effective de `reasoning` quand le profil ne la porte pas.
    #[must_use]
    pub fn reasoning_enabled(&self) -> bool {
        self.reasoning.unwrap_or(true)
    }

    /// Valeur effective de `attachment` quand le profil ne la porte pas.
    #[must_use]
    pub fn attachment_enabled(&self) -> bool {
        self.attachment.unwrap_or(false)
    }
}

/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
///
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
/// et `api_key_env` porte uniquement le NOM de variable d'environnement à résoudre
/// côté infrastructure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpChatConfig {
    /// Endpoint racine ou endpoint `/chat/completions` du serveur HTTP.
    pub endpoint: String,
    /// Nom du modèle envoyé dans le payload `/chat/completions`.
    pub model: String,
    /// Nom optionnel de la variable d'environnement contenant la clé API.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_env: Option<String>,
    /// Timeout de requête par tour, en millisecondes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request_timeout_ms: Option<u32>,
    /// Timeout de connexion, en millisecondes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub connect_timeout_ms: Option<u32>,
    /// Plafond de rebouclage tool-calling pour un tour.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_tool_iterations: Option<u16>,
}

impl HttpChatConfig {
    /// Défaut produit du garde-fou tool-calling local/LAN.
    pub const DEFAULT_MAX_TOOL_ITERATIONS: u16 = 16;

    /// Construit une configuration validée (parse-don't-validate, sans I/O).
    ///
    /// # Errors
    /// Renvoie une erreur domaine si l'endpoint n'est pas HTTP(S), si le modèle est
    /// vide, si `api_key_env` n'est pas un nom d'env valide, ou si un timeout/plafond
    /// vaut zéro.
    pub fn new(
        endpoint: impl Into<String>,
        model: impl Into<String>,
        api_key_env: Option<String>,
        request_timeout_ms: Option<u32>,
        connect_timeout_ms: Option<u32>,
        max_tool_iterations: Option<u16>,
    ) -> Result<Self, DomainError> {
        let endpoint = endpoint.into();
        let model = model.into();
        crate::validation::non_empty(&endpoint, "chatHttp.endpoint")?;
        if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
            return Err(DomainError::Invariant(
                "chatHttp.endpoint must start with http:// or https://".to_owned(),
            ));
        }
        crate::validation::non_empty(&model, "chatHttp.model")?;
        if let Some(var) = &api_key_env {
            crate::validation::valid_env_var(var)?;
        }
        if let Some(0) = request_timeout_ms {
            return Err(DomainError::EmptyField {
                field: "chatHttp.requestTimeoutMs",
            });
        }
        if let Some(0) = connect_timeout_ms {
            return Err(DomainError::EmptyField {
                field: "chatHttp.connectTimeoutMs",
            });
        }
        if let Some(0) = max_tool_iterations {
            return Err(DomainError::EmptyField {
                field: "chatHttp.maxToolIterations",
            });
        }
        Ok(Self {
            endpoint,
            model,
            api_key_env,
            request_timeout_ms,
            connect_timeout_ms,
            max_tool_iterations,
        })
    }

    /// Plafond effectif de rebouclage tool-calling.
    #[must_use]
    pub const fn effective_max_tool_iterations(&self) -> u16 {
        match self.max_tool_iterations {
            Some(value) => value,
            None => Self::DEFAULT_MAX_TOOL_ITERATIONS,
        }
    }
}

/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
///
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
/// Déclaratif, Open/Closed (comme [`StructuredAdapter`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
    /// Pont stdio (défaut) : IdeA parle au serveur MCP via stdin/stdout.
    #[default]
    Stdio,
    /// Socket partagé (adresse) : optimisation, point ouvert (spike S-MCP).
    Socket,
}

/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
///
/// Invariants (garantis par les constructeurs) :
/// - `ConfigFile.target` est un chemin relatif sûr (pas de `..`, pas d'absolu),
/// - `Flag.flag` est non vide,
/// - `Env.var` est un identifiant de variable d'environnement valide.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum McpConfigStrategy {
    /// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
    /// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
    ConfigFile {
        /// Chemin relatif sûr du fichier de conf MCP.
        target: String,
    },
    /// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
    Flag {
        /// Le flag (template) non vide.
        flag: String,
    },
    /// Passer via une variable d'environnement.
    Env {
        /// Nom de la variable d'environnement.
        var: String,
    },
    /// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
    /// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
    /// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
    /// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
    /// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
    /// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
    /// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
    /// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
    TomlConfigHome {
        /// Chemin relatif sûr du fichier `config.toml` (convention :
        /// `".codex/config.toml"`).
        target: String,
        /// Nom de la variable d'environnement pointée sur le **dossier parent** de
        /// `target` (ex. `"CODEX_HOME"`).
        home_env: String,
    },
    /// Écrire une configuration OpenCode complète (`opencode.json`) dans le run dir.
    /// Ce format porte à la fois le provider Ollama et le serveur MCP local `idea`.
    OpenCodeConfig {
        /// Chemin relatif sûr du fichier de config OpenCode.
        target: String,
    },
}

impl McpConfigStrategy {
    /// Constructeur validé `ConfigFile`.
    ///
    /// # Errors
    /// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou
    /// contient `..`.
    pub fn config_file(target: impl Into<String>) -> Result<Self, DomainError> {
        let target = target.into();
        crate::validation::relative_safe(&target)?;
        Ok(Self::ConfigFile { target })
    }

    /// Constructeur validé `Flag`.
    ///
    /// # Errors
    /// Renvoie [`DomainError::EmptyField`] si `flag` est vide.
    pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
        let flag = flag.into();
        crate::validation::non_empty(&flag, "mcp.config.flag")?;
        Ok(Self::Flag { flag })
    }

    /// Constructeur validé `Env`.
    ///
    /// # Errors
    /// Renvoie [`DomainError::InvalidEnvVar`] si `var` n'est pas un identifiant
    /// valide.
    pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
        let var = var.into();
        crate::validation::valid_env_var(&var)?;
        Ok(Self::Env { var })
    }

    /// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
    ///
    /// # Errors
    /// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
    ///   (même validation que [`Self::config_file`]),
    /// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
    ///   variable d'environnement valide.
    pub fn toml_config_home(
        target: impl Into<String>,
        home_env: impl Into<String>,
    ) -> Result<Self, DomainError> {
        let target = target.into();
        let home_env = home_env.into();
        crate::validation::relative_safe(&target)?;
        crate::validation::valid_env_var(&home_env)?;
        Ok(Self::TomlConfigHome { target, home_env })
    }

    /// Constructeur validé `OpenCodeConfig`.
    ///
    /// # Errors
    /// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou
    /// contient `..`.
    pub fn open_code_config(target: impl Into<String>) -> Result<Self, DomainError> {
        let target = target.into();
        crate::validation::relative_safe(&target)?;
        Ok(Self::OpenCodeConfig { target })
    }
}

/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` +
/// prose (comportement actuel, zéro régression).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapability {
    /// Comment matérialiser la config MCP au lancement (relatif au run dir).
    pub config: McpConfigStrategy,
    /// Transport du serveur MCP IdeA (détail invisible au domaine).
    /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation.
    #[serde(default)]
    pub transport: McpTransport,
}

impl McpCapability {
    /// Construit une capacité MCP. La validation est portée par le constructeur
    /// de [`McpConfigStrategy`] (parse, don't validate) ; `transport` n'a pas
    /// d'invariant.
    #[must_use]
    pub const fn new(config: McpConfigStrategy, transport: McpTransport) -> Self {
        Self { config, transport }
    }
}

/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
///
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
/// domaine ne fait que la **mise en forme**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerWiring {
    /// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
    /// déclaration minimale dégradée).
    pub command: String,
    /// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
    pub args: Vec<String>,
    /// Transport du serveur MCP (surfacé dans les deux formats).
    pub transport: McpTransport,
}

impl McpServerWiring {
    /// Codex's MCP client defaults tool calls to a short interactive timeout (120s on
    /// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous
    /// that can legitimately span a full delegated dev/test turn, so the generated
    /// server config must widen the per-tool timeout.
    pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60;

    /// Construit le wiring depuis ses parties.
    #[must_use]
    pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
        Self {
            command,
            args,
            transport,
        }
    }

    /// Étiquette stable du transport, identique pour les deux formats.
    #[must_use]
    const fn transport_label(&self) -> &'static str {
        match self.transport {
            McpTransport::Stdio => "stdio",
            McpTransport::Socket => "socket",
        }
    }

    /// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
    /// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
    /// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
    /// valides).
    #[must_use]
    pub fn to_mcp_json(&self) -> String {
        let command = json_string(&self.command);
        let args = if self.args.is_empty() {
            String::new()
        } else {
            let joined = self
                .args
                .iter()
                .map(|a| format!("\n        {}", json_string(a)))
                .collect::<Vec<_>>()
                .join(",");
            format!("{joined}\n      ")
        };
        let transport = self.transport_label();
        format!(
            r#"{{
  "mcpServers": {{
    "idea": {{
      "command": {command},
      "args": [{args}],
      "transport": "{transport}"
    }}
  }}
}}
"#
        )
    }

    /// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
    /// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique
    /// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML
    /// (équivalent du `json_string` : espaces/backslash/quotes restent valides).
    #[must_use]
    pub fn to_config_toml(&self) -> String {
        let command = toml_string(&self.command);
        let args = self
            .args
            .iter()
            .map(|a| toml_string(a))
            .collect::<Vec<_>>()
            .join(", ");
        let transport = self.transport_label();
        let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
        format!(
            "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
        )
    }
}

/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
/// exe/endpoint avec espaces, backslash ou quotes.
fn json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
/// `\`, et les contrôles.
fn toml_string(s: &str) -> String {
    // Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
    // JSON pour les caractères qui nous concernent (chemins, flags).
    json_string(s)
}

/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
/// - `name` and `command` non-empty,
/// - `context_injection` is itself valid (guaranteed by its constructors),
/// - `session` is itself valid (guaranteed by its constructor).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
    /// Stable identifier.
    pub id: ProfileId,
    /// Display name.
    pub name: String,
    /// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
    pub command: String,
    /// Static arguments.
    pub args: Vec<String>,
    /// Context-injection strategy.
    pub context_injection: ContextInjection,
    /// Optional detection command (e.g. `claude --version`).
    pub detect: Option<String>,
    /// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
    /// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
    /// **never** the project root, so that N agents of the same profile never
    /// collide on a single conventional file (`CLAUDE.md`, …) at the root.
    pub cwd_template: String,
    /// Optional conversation assign/resume strategy. Absent (legacy / no resume)
    /// keeps today's behaviour.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session: Option<SessionStrategy>,
    /// Adapter d'exécution **structurée** (ARCHITECTURE §17). `None` ⇒ agent
    /// **TUI/PTY** (cellule terminal brut, comportement historique). `Some(_)` ⇒
    /// agent **IA structuré** (cellule chat + port [`crate::ports::AgentSession`]).
    /// Open/Closed : ajouter un moteur = une variante + un adapter.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
    /// sérialisation : un profil sans adapter sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub structured_adapter: Option<StructuredAdapter>,
    /// Configuration HTTP pour [`StructuredAdapter::OpenAiCompatible`]. `None` pour
    /// tous les profils historiques et tous les adapters non HTTP.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chat_http: Option<HttpChatConfig>,
    /// Configuration OpenCode pour [`StructuredAdapter::OpenCode`]. Distincte de
    /// [`Self::chat_http`] : OpenCode est un host process MCP natif, pas l'adapter
    /// HTTP OpenAI-compatible in-process.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub opencode: Option<OpenCodeConfig>,
    /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
    /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
    /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
    /// l'agent voit les outils `idea_*`. Additif, Open/Closed.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
    /// sérialisation : un profil sans MCP sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcp: Option<McpCapability>,
    /// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
    /// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
    /// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
    /// timeout de tour du lot 2).
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
    /// un profil sans cette clé sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub liveness: Option<LivenessStrategy>,
    /// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE
    /// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune**
    /// détection par motif : seuls les agents structurés (niveau 1) ou le filet
    /// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans
    /// adapter structuré**, IdeA observe la sortie et émet un
    /// [`crate::ports::ReplyEvent::RateLimited`] sur match.
    ///
    /// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex
    /// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro
    /// préservé.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
    /// un profil sans cette clé sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rate_limit_pattern: Option<RateLimitPattern>,
    /// Séquence de soumission écrite **après** le texte d'une délégation pour la
    /// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
    /// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
    /// la TUI), puis **cette séquence seule** après un court délai.
    ///
    /// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
    /// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
    /// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
    /// sans cette clé sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub submit_sequence: Option<String>,
    /// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
    /// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
    ///
    /// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
    /// en dur dans le domaine.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub submit_delay_ms: Option<u32>,
    /// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter
    /// traduit les [`crate::permission::EffectivePermissions`] résolues en config
    /// de permission concrète de cette CLI au lancement. Donnée **déclarative**
    /// (Open/Closed), calquée sur [`StructuredAdapter`].
    ///
    /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection :
    /// la CLI garde son prompting natif (invariant produit de
    /// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de
    /// permission de cette CLI au lancement.
    ///
    /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
    /// sérialisation : un profil sans cette clé sérialise exactement comme avant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub projector: Option<ProjectorKey>,
}

/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
///
/// Declarative, Open/Closed: a new engine family is a new variant *only* if it
/// changes the adapter's dispatch; otherwise it is pure data on the profile
/// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None):
/// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbedderStrategy {
    /// A local ONNX model run in-process (real integration is a follow-up).
    LocalOnnx,
    /// A local embedding server reached over HTTP (real integration is a follow-up).
    LocalServer,
    /// A remote embedding API (real integration is a follow-up).
    Api,
    /// No embedding engine: recall stays the dependency-free naïve étage 1.
    None,
}

/// Declarative configuration for one embedding engine (LOT C, étage 2).
///
/// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json`
/// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed).
///
/// Invariants:
/// - `id` and `name` non-empty,
/// - `dimension` is non-zero (an embedder always produces fixed-length vectors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderProfile {
    /// Stable identifier (e.g. `"local-onnx-minilm"`).
    pub id: String,
    /// Display name.
    pub name: String,
    /// Embedding strategy driving which concrete adapter is used.
    pub strategy: EmbedderStrategy,
    /// Model identifier (e.g. an ONNX model name), when the strategy needs one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Endpoint URL for a server/API strategy.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub endpoint: Option<String>,
    /// Name of the environment variable carrying the API key (never the key
    /// itself), for an `api` strategy.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_env: Option<String>,
    /// Length of the vectors this engine produces.
    pub dimension: usize,
}

impl EmbedderProfile {
    /// Builds a validated embedder profile.
    ///
    /// # Errors
    /// - [`DomainError::EmptyField`] if `id` or `name` is empty,
    /// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`.
    pub fn new(
        id: impl Into<String>,
        name: impl Into<String>,
        strategy: EmbedderStrategy,
        model: Option<String>,
        endpoint: Option<String>,
        api_key_env: Option<String>,
        dimension: usize,
    ) -> Result<Self, DomainError> {
        let id = id.into();
        let name = name.into();
        crate::validation::non_empty(&id, "embedder.id")?;
        crate::validation::non_empty(&name, "embedder.name")?;
        if dimension == 0 {
            return Err(DomainError::EmptyField {
                field: "embedder.dimension",
            });
        }
        Ok(Self {
            id,
            name,
            strategy,
            model,
            endpoint,
            api_key_env,
            dimension,
        })
    }

    /// A dependency-free default profile: strategy [`EmbedderStrategy::None`].
    /// Recall stays the naïve étage 1 — nothing is imposed.
    #[must_use]
    pub fn none() -> Self {
        Self {
            id: "none".to_owned(),
            name: "None".to_owned(),
            strategy: EmbedderStrategy::None,
            model: None,
            endpoint: None,
            api_key_env: None,
            dimension: 1,
        }
    }
}

impl AgentProfile {
    /// Builds a validated profile.
    ///
    /// # Errors
    /// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        id: ProfileId,
        name: impl Into<String>,
        command: impl Into<String>,
        args: Vec<String>,
        context_injection: ContextInjection,
        detect: Option<String>,
        cwd_template: impl Into<String>,
        session: Option<SessionStrategy>,
    ) -> Result<Self, DomainError> {
        let name = name.into();
        let command = command.into();
        let cwd_template = cwd_template.into();
        crate::validation::non_empty(&name, "profile.name")?;
        crate::validation::non_empty(&command, "profile.command")?;
        Ok(Self {
            id,
            name,
            command,
            args,
            context_injection,
            detect,
            cwd_template,
            session,
            structured_adapter: None,
            chat_http: None,
            opencode: None,
            mcp: None,
            liveness: None,
            rate_limit_pattern: None,
            submit_sequence: None,
            submit_delay_ms: None,
            projector: None,
        })
    }

    /// Builder : fixe l'[`StructuredAdapter`] d'exécution structurée (§17) et
    /// renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
    /// d'appel) : les profils TUI/PTY ne l'appellent simplement pas.
    #[must_use]
    pub fn with_structured_adapter(mut self, adapter: StructuredAdapter) -> Self {
        self.structured_adapter = Some(adapter);
        self
    }

    /// Builder : fixe la configuration HTTP chat OpenAI-compatible.
    #[must_use]
    pub fn with_chat_http(mut self, config: HttpChatConfig) -> Self {
        self.chat_http = Some(config);
        self
    }

    /// Builder : fixe la configuration OpenCode process-backed.
    #[must_use]
    pub fn with_opencode(mut self, config: OpenCodeConfig) -> Self {
        self.opencode = Some(config);
        self
    }

    /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
    /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
    /// profils sans MCP ne l'appellent simplement pas.
    #[must_use]
    pub fn with_mcp(mut self, mcp: McpCapability) -> Self {
        self.mcp = Some(mcp);
        self
    }

    /// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
    /// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
    /// profils sans réglage de vivacité ne l'appellent simplement pas.
    #[must_use]
    pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
        self.liveness = Some(liveness);
        self
    }

    /// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2)
    /// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
    /// d'appel) : les profils sans détection par motif ne l'appellent simplement pas.
    #[must_use]
    pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self {
        self.rate_limit_pattern = Some(pattern);
        self
    }

    /// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
    /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
    /// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
    #[must_use]
    pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
        self.submit_sequence = Some(sequence.into());
        self
    }

    /// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
    /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
    #[must_use]
    pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
        self.submit_delay_ms = Some(delay_ms);
        self
    }

    /// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le
    /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
    /// profils sans projection de permission ne l'appellent simplement pas.
    #[must_use]
    pub const fn with_projector(mut self, projector: ProjectorKey) -> Self {
        self.projector = Some(projector);
        self
    }

    /// Indique si ce profil peut être **proposé à la sélection/création** d'un
    /// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
    /// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
    /// est pilotable en mode structuré. C'est exactement le prédicat que
    /// l'`AgentSessionFactory` utilise pour décider qu'il sait piloter le profil
    /// (`supports`) : les deux doivent rester d'accord.
    ///
    /// Ceci ne restreint **que** ce qui est offert à la création : un profil
    /// sans adapter reste un profil PTY/legacy valide, persistable et exécutable
    /// (zéro régression sur l'existant).
    #[must_use]
    pub fn is_selectable(&self) -> bool {
        self.structured_adapter.is_some()
    }

    /// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
    /// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
    /// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
    /// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
    ///
    /// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
    /// IdeA sait écrire la config que **cette** CLI lit nativement :
    /// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
    ///   dans son cwd (run dir isolé) ;
    /// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
    ///   qu'IdeA isole dans le run dir via `home_env` ;
    /// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
    ///   `.ideai/requests` + prose, le pont natif n'est pas branché.
    ///
    /// Cette fonction centralise le critère pour que la garde applicative
    /// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
    #[must_use]
    pub fn materializes_idea_bridge(&self) -> bool {
        match (
            self.structured_adapter,
            self.mcp.as_ref().map(|c| &c.config),
        ) {
            (Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
                target == ".mcp.json"
            }
            (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
                true
            }
            (
                Some(StructuredAdapter::OpenCode),
                Some(McpConfigStrategy::OpenCodeConfig { target }),
            ) => target == "opencode.json",
            (Some(StructuredAdapter::OpenAiCompatible), _) => false,
            _ => false,
        }
    }
}

#[cfg(test)]
mod mcp_tests {
    use super::*;
    use crate::ids::{LocalModelServerId, ProfileId};

    /// A reference profile without any MCP capability (the historical shape).
    fn profile_without_mcp() -> AgentProfile {
        AgentProfile::new(
            ProfileId::from_uuid(uuid::Uuid::nil()),
            "Dev",
            "claude",
            vec!["-p".to_owned()],
            ContextInjection::convention_file("CLAUDE.md").expect("valid target"),
            Some("claude --version".to_owned()),
            "{agentRunDir}",
            None,
        )
        .expect("valid profile")
    }

    // -- Critère 1 : zéro régression de sérialisation (mcp = None) --------------

    #[test]
    fn profile_without_mcp_omits_mcp_key_in_json() {
        let profile = profile_without_mcp();
        assert!(profile.mcp.is_none());

        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(
            !json.contains("\"mcp\""),
            "a profile without MCP must NOT serialise an `mcp` key (zero regression); got: {json}"
        );
    }

    #[test]
    fn profile_without_mcp_round_trips_identically() {
        let profile = profile_without_mcp();
        let json = serde_json::to_string(&profile).expect("serialise");
        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
        assert!(back.mcp.is_none());
    }

    #[test]
    fn profile_without_chat_http_round_trips_identically() {
        let profile = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::Claude)
            .with_mcp(McpCapability::new(
                McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
                McpTransport::Stdio,
            ));
        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(
            !json.contains("\"chatHttp\""),
            "a non-HTTP profile must NOT serialise `chatHttp`: {json}"
        );
        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
        assert!(back.chat_http.is_none());
    }

    #[test]
    fn openai_compatible_adapter_serialises_camel_case_and_provider_key_is_stable() {
        let adapter = StructuredAdapter::OpenAiCompatible;
        assert_eq!(adapter.provider_key(), "openai-compatible");
        let json = serde_json::to_string(&adapter).expect("serialise");
        assert_eq!(json, "\"openAiCompatible\"");
        let back: StructuredAdapter = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(back, adapter);
    }

    #[test]
    fn opencode_adapter_is_distinct_and_materialises_open_code_config_only() {
        let adapter = StructuredAdapter::OpenCode;
        assert_eq!(adapter.provider_key(), "opencode");
        let json = serde_json::to_string(&adapter).expect("serialise");
        assert_eq!(json, "\"openCode\"");
        let back: StructuredAdapter = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(back, adapter);

        let profile = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::OpenCode)
            .with_opencode(
                OpenCodeConfig::new(
                    "http://localhost:8080/v1",
                    Some("sk-no-key".to_owned()),
                    "qwen3-coder-30b",
                    None,
                    None,
                )
                .unwrap(),
            )
            .with_mcp(McpCapability::new(
                McpConfigStrategy::open_code_config("opencode.json").unwrap(),
                McpTransport::Stdio,
            ));
        assert!(profile.materializes_idea_bridge());
    }

    #[test]
    fn opencode_config_validates_llamacpp_endpoint_and_model() {
        let config = OpenCodeConfig::new(
            "https://localhost:8080/v1",
            None,
            "qwen3-coder-30b",
            None,
            Some(true),
        )
        .unwrap();
        assert_eq!(config.base_url, "https://localhost:8080/v1");
        assert_eq!(config.model, "qwen3-coder-30b");
        assert!(config.reasoning_enabled());
        assert!(config.attachment_enabled());

        assert!(OpenCodeConfig::new("", None, "qwen3-coder-30b", None, None).is_err());
        assert!(
            OpenCodeConfig::new("file:///tmp/v1", None, "qwen3-coder-30b", None, None).is_err()
        );
        assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
    }

    #[test]
    fn opencode_config_omits_local_model_server_id_when_absent() {
        let config = OpenCodeConfig::new(
            "http://localhost:8080/v1",
            None,
            "qwen3-coder-30b",
            None,
            None,
        )
        .unwrap();

        let json = serde_json::to_string(&config).expect("serialise");
        assert!(
            !json.contains("localModelServerId"),
            "manual external OpenCode profiles must keep the legacy JSON shape: {json}"
        );
        let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(back.local_model_server_id, None);
    }

    #[test]
    fn opencode_config_serialises_local_model_server_id_camelcase() {
        let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
        let config = OpenCodeConfig::new(
            "http://localhost:8080/v1",
            None,
            "qwen3-coder-30b",
            None,
            None,
        )
        .unwrap()
        .with_local_model_server_id(server_id);

        let value = serde_json::to_value(&config).expect("serialise");
        assert_eq!(value["localModelServerId"], server_id.to_string());
        assert!(value.get("local_model_server_id").is_none());
        let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise");
        assert_eq!(back.local_model_server_id, Some(server_id));
    }

    #[test]
    fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
        let profile = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::OpenAiCompatible)
            .with_mcp(McpCapability::new(
                McpConfigStrategy::open_code_config("opencode.json").unwrap(),
                McpTransport::Stdio,
            ));
        assert!(!profile.materializes_idea_bridge());
    }

    #[test]
    fn http_chat_config_validates_without_io_and_round_trips() {
        let config = HttpChatConfig::new(
            "http://127.0.0.1:11434/v1",
            "qwen2.5-coder",
            Some("OLLAMA_API_KEY".to_owned()),
            Some(30_000),
            Some(3_000),
            Some(8),
        )
        .expect("valid");
        assert_eq!(config.effective_max_tool_iterations(), 8);
        let json = serde_json::to_string(&config).expect("serialise");
        assert!(json.contains("\"apiKeyEnv\":\"OLLAMA_API_KEY\""));
        let back: HttpChatConfig = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(back, config);
    }

    #[test]
    fn http_chat_config_rejects_invalid_values() {
        assert!(HttpChatConfig::new("ftp://host", "model", None, None, None, None).is_err());
        assert!(HttpChatConfig::new("http://host", " ", None, None, None, None).is_err());
        assert!(HttpChatConfig::new(
            "http://host",
            "model",
            Some("not-valid".to_owned()),
            None,
            None,
            None
        )
        .is_err());
        assert!(HttpChatConfig::new("http://host", "model", None, Some(0), None, None).is_err());
        assert!(HttpChatConfig::new("http://host", "model", None, None, Some(0), None).is_err());
        assert!(HttpChatConfig::new("http://host", "model", None, None, None, Some(0)).is_err());
    }

    #[test]
    fn legacy_json_without_mcp_field_deserialises_to_none() {
        // JSON produced before the `mcp` field existed: no `mcp` key at all.
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}"
        }"#;
        let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert!(profile.mcp.is_none());
    }

    // -- Critère 2 : round-trip avec MCP (les 3 stratégies) ---------------------

    #[test]
    fn profile_with_mcp_config_file_round_trips() {
        let cap = McpCapability::new(
            McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
            McpTransport::Stdio,
        );
        let profile = profile_without_mcp().with_mcp(cap.clone());
        assert_eq!(profile.mcp, Some(cap));

        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(json.contains("\"mcp\""), "mcp key must be present: {json}");
        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
    }

    #[test]
    fn mcp_capability_flag_strategy_round_trips() {
        let cap = McpCapability::new(
            McpConfigStrategy::flag("--mcp-config {path}").expect("valid flag"),
            McpTransport::Socket,
        );
        let json = serde_json::to_string(&cap).expect("serialise");
        let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(cap, back);
        assert_eq!(back.transport, McpTransport::Socket);
        assert!(matches!(back.config, McpConfigStrategy::Flag { .. }));
    }

    #[test]
    fn mcp_capability_env_strategy_round_trips() {
        let cap = McpCapability::new(
            McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid env"),
            McpTransport::Stdio,
        );
        let json = serde_json::to_string(&cap).expect("serialise");
        let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(cap, back);
        assert!(matches!(back.config, McpConfigStrategy::Env { .. }));
    }

    #[test]
    fn mcp_config_strategy_uses_tagged_camel_case() {
        // The `strategy` tag and camelCase rename are part of the wire contract.
        let json =
            serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
                .expect("serialise");
        assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
    }

    // -- Critère 4 : transport par défaut = Stdio -------------------------------

    #[test]
    fn transport_default_is_stdio() {
        assert_eq!(McpTransport::default(), McpTransport::Stdio);
    }

    #[test]
    fn mcp_capability_without_transport_defaults_to_stdio() {
        // `transport` is `#[serde(default)]`: an MCP capability serialised without
        // it must deserialise to Stdio.
        let json = r#"{ "config": { "strategy": "configFile", "target": ".mcp.json" } }"#;
        let cap: McpCapability = serde_json::from_str(json).expect("deserialise");
        assert_eq!(cap.transport, McpTransport::Stdio);
    }

    // -- Critère 3 : validation des constructeurs -------------------------------

    #[test]
    fn config_file_rejects_absolute_target() {
        let err = McpConfigStrategy::config_file("/etc/mcp.json").unwrap_err();
        assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
    }

    #[test]
    fn config_file_rejects_parent_traversal() {
        let err = McpConfigStrategy::config_file("../escape/.mcp.json").unwrap_err();
        assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
    }

    #[test]
    fn config_file_accepts_safe_relative_target() {
        let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
        assert_eq!(
            s,
            McpConfigStrategy::ConfigFile {
                target: ".mcp.json".to_owned()
            }
        );
    }

    #[test]
    fn flag_rejects_empty() {
        let err = McpConfigStrategy::flag("").unwrap_err();
        assert!(matches!(err, DomainError::EmptyField { .. }));
    }

    #[test]
    fn flag_accepts_non_empty() {
        let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
        assert_eq!(
            s,
            McpConfigStrategy::Flag {
                flag: "--mcp-config {path}".to_owned()
            }
        );
    }

    #[test]
    fn env_rejects_invalid_name() {
        let err = McpConfigStrategy::env("1BAD-NAME").unwrap_err();
        assert!(matches!(err, DomainError::InvalidEnvVar { .. }));
    }

    #[test]
    fn env_accepts_valid_name() {
        let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
        assert_eq!(
            s,
            McpConfigStrategy::Env {
                var: "IDEA_MCP_SERVER".to_owned()
            }
        );
    }

    // -- Backstop no-reply : le champ `promptReadyPattern` a été retiré ----------

    #[test]
    fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() {
        // Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher
        // transcript). Un `profiles.json` antérieur portant encore la clé doit
        // continuer à se désérialiser (pas de `deny_unknown_fields`) : la clé obsolète
        // est simplement ignorée — zéro régression de lecture des profils existants.
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}",
            "promptReadyPattern": "? for shortcuts"
        }"#;
        let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert_eq!(profile.command, "claude");
    }

    // -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------

    #[test]
    fn profile_default_has_no_submit_fields() {
        let p = profile_without_mcp();
        assert!(p.submit_sequence.is_none());
        assert!(p.submit_delay_ms.is_none());
    }

    #[test]
    fn profile_without_submit_fields_omits_keys_in_json() {
        let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
        assert!(
            !json.contains("submitSequence"),
            "no submitSequence key when None (zero regression); got: {json}"
        );
        assert!(
            !json.contains("submitDelayMs"),
            "no submitDelayMs key when None (zero regression); got: {json}"
        );
    }

    #[test]
    fn legacy_json_without_submit_fields_deserialises_to_none() {
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}"
        }"#;
        let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert!(p.submit_sequence.is_none());
        assert!(p.submit_delay_ms.is_none());
    }

    #[test]
    fn with_submit_fields_set_and_round_trip_camel_case() {
        let p = profile_without_mcp()
            .with_submit_sequence("\r")
            .with_submit_delay_ms(60);
        assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
        assert_eq!(p.submit_delay_ms, Some(60));

        let json = serde_json::to_string(&p).expect("serialise");
        assert!(json.contains("submitSequence"), "key present: {json}");
        assert!(json.contains("submitDelayMs"), "key present: {json}");

        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(p, back);
        assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
        assert_eq!(back.submit_delay_ms, Some(60));
    }

    // -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --

    #[test]
    fn profile_default_has_no_liveness() {
        // Profils existants (via `new`) : aucun réglage de vivacité.
        assert!(profile_without_mcp().liveness.is_none());
    }

    #[test]
    fn profile_without_liveness_omits_key_in_json() {
        let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
        assert!(
            !json.contains("liveness"),
            "a profile without liveness must NOT serialise the key (zero regression); got: {json}"
        );
    }

    #[test]
    fn legacy_json_without_liveness_deserialises_to_none() {
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}"
        }"#;
        let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert!(profile.liveness.is_none());
    }

    #[test]
    fn with_liveness_sets_and_round_trips_camel_case() {
        let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
        let profile = profile_without_mcp().with_liveness(liveness);
        assert_eq!(profile.liveness, Some(liveness));

        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(json.contains("liveness"), "key present: {json}");
        assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
        assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");

        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
        assert_eq!(back.liveness, Some(liveness));
    }

    #[test]
    fn liveness_omits_unset_thresholds_in_json() {
        // Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
        let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
        let json = serde_json::to_string(&liveness).expect("serialise");
        assert!(
            !json.contains("stallAfterMs"),
            "an unset stall threshold must be omitted; got: {json}"
        );
        assert!(
            json.contains("turnTimeoutMs"),
            "set threshold present: {json}"
        );
    }

    #[test]
    fn liveness_new_rejects_zero_thresholds() {
        assert!(matches!(
            LivenessStrategy::new(Some(0), None).unwrap_err(),
            DomainError::EmptyField { .. }
        ));
        assert!(matches!(
            LivenessStrategy::new(None, Some(0)).unwrap_err(),
            DomainError::EmptyField { .. }
        ));
        // Les deux None : valide (= « aucun seuil »).
        assert!(LivenessStrategy::new(None, None).is_ok());
    }

    // -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------

    #[test]
    fn toml_config_home_round_trips_with_tagged_strategy() {
        let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
            .expect("valid toml config home");
        let json = serde_json::to_string(&strategy).expect("serialise");

        // Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
        assert!(
            json.contains("\"strategy\":\"tomlConfigHome\""),
            "tagged camelCase variant expected; got: {json}"
        );
        assert!(
            json.contains("\"target\":\".codex/config.toml\""),
            "target field expected; got: {json}"
        );
        // NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
        // fields of a struct variant, so `home_env` stays snake_case on the wire.
        assert!(
            json.contains("\"home_env\":\"CODEX_HOME\""),
            "home_env field expected; got: {json}"
        );

        let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(strategy, back);
    }

    #[test]
    fn toml_config_home_rejects_absolute_and_parent_target() {
        let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
        assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));

        let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
        assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
    }

    #[test]
    fn toml_config_home_rejects_invalid_home_env() {
        // Empty home_env: first char is None ⇒ invalid identifier.
        let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
        assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));

        // Illegal character in the env var name.
        let illegal =
            McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
        assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
    }

    #[test]
    fn materializes_idea_bridge_matrix() {
        // Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
        let claude = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::Claude)
            .with_mcp(McpCapability::new(
                McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
                McpTransport::Stdio,
            ));
        assert!(claude.materializes_idea_bridge());

        // Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
        // be refused before this surface existed).
        let codex = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::Codex)
            .with_mcp(McpCapability::new(
                McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
                    .expect("valid toml config home"),
                McpTransport::Stdio,
            ));
        assert!(codex.materializes_idea_bridge());

        // Codex WITHOUT any MCP capability ⇒ no bridge.
        let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
        assert!(!codex_no_mcp.materializes_idea_bridge());

        // Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
        let codex_wrong_strategy = profile_without_mcp()
            .with_structured_adapter(StructuredAdapter::Codex)
            .with_mcp(McpCapability::new(
                McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
                McpTransport::Stdio,
            ));
        assert!(!codex_wrong_strategy.materializes_idea_bridge());
    }

    // -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------

    #[test]
    fn profile_default_has_no_projector() {
        // Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif.
        assert!(profile_without_mcp().projector.is_none());
    }

    #[test]
    fn profile_without_projector_omits_key_in_json() {
        let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
        assert!(
            !json.contains("projector"),
            "a profile without a projector must NOT serialise the key (zero regression); got: {json}"
        );
    }

    #[test]
    fn legacy_json_without_projector_deserialises_to_none() {
        // JSON produit avant l'existence du champ `projector` : aucune clé `projector`.
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}"
        }"#;
        let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert!(profile.projector.is_none());
    }

    #[test]
    fn with_projector_sets_and_round_trips_camel_case() {
        let profile = profile_without_mcp().with_projector(ProjectorKey::Claude);
        assert_eq!(profile.projector, Some(ProjectorKey::Claude));

        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(
            json.contains("\"projector\":\"claude\""),
            "projector key present in stable wire form: {json}"
        );

        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
        assert_eq!(back.projector, Some(ProjectorKey::Claude));
    }

    #[test]
    fn mcp_server_wiring_encodes_expected_toml() {
        // A command path with a space and a backslash exercises TOML escaping.
        let wiring = McpServerWiring::new(
            "/opt/My Apps\\idea".to_owned(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                "/tmp/sock 1".to_owned(),
            ],
            McpTransport::Stdio,
        );
        let toml = wiring.to_config_toml();

        // Table header present.
        assert!(
            toml.contains("[mcp_servers.idea]"),
            "table header expected; got: {toml}"
        );
        // Command path escaped as a TOML basic string (backslash doubled, space kept).
        assert!(
            toml.contains("command = \"/opt/My Apps\\\\idea\""),
            "escaped command path expected; got: {toml}"
        );
        // Args preserved in order, as a TOML array.
        assert!(
            toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
            "args in order expected; got: {toml}"
        );
        // Transport surfaced.
        assert!(
            toml.contains("transport = \"stdio\""),
            "transport expected; got: {toml}"
        );
        // IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting
        // `.mcp.json` path.
        assert!(
            toml.contains("default_tools_approval_mode = \"approve\""),
            "IdeA MCP tools should not prompt for approval; got: {toml}"
        );
        assert!(
            toml.contains("tool_timeout_sec = 86400"),
            "IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
        );
    }

    // -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------

    #[test]
    fn rate_limit_pattern_new_rejects_empty_pattern() {
        let err = RateLimitPattern::new("", None, None).unwrap_err();
        assert!(
            matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"),
            "un motif vide doit être rejeté; got: {err:?}"
        );
    }

    #[test]
    fn rate_limit_pattern_new_accepts_non_empty_pattern() {
        let p = RateLimitPattern::new(
            "rate limit.*resets at (?P<reset>.+)",
            Some("reset".to_owned()),
            Some("%H:%M".to_owned()),
        )
        .expect("valid pattern");
        assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
        assert_eq!(p.reset_capture.as_deref(), Some("reset"));
        assert_eq!(p.time_format.as_deref(), Some("%H:%M"));
    }

    #[test]
    fn profile_default_has_no_rate_limit_pattern() {
        // Profils existants (via `new`) : aucun motif de limite.
        assert!(profile_without_mcp().rate_limit_pattern.is_none());
    }

    #[test]
    fn profile_without_rate_limit_pattern_omits_key_in_json() {
        let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
        assert!(
            !json.contains("rateLimitPattern"),
            "a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}"
        );
    }

    #[test]
    fn legacy_json_without_rate_limit_pattern_deserialises_to_none() {
        // JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`.
        let legacy = r#"{
            "id": "00000000-0000-0000-0000-000000000000",
            "name": "Dev",
            "command": "claude",
            "args": [],
            "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
            "detect": null,
            "cwdTemplate": "{agentRunDir}"
        }"#;
        let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
        assert!(profile.rate_limit_pattern.is_none());
    }

    #[test]
    fn with_rate_limit_pattern_sets_and_round_trips_camel_case() {
        let pattern = RateLimitPattern::new(
            "limit reached, resets (?P<resetAt>.+)",
            Some("resetAt".to_owned()),
            Some("%-I%p".to_owned()),
        )
        .expect("valid pattern");
        let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone());
        assert_eq!(profile.rate_limit_pattern, Some(pattern));

        let json = serde_json::to_string(&profile).expect("serialise");
        assert!(json.contains("rateLimitPattern"), "key present: {json}");
        // camelCase respecté sur les champs de RateLimitPattern.
        assert!(
            json.contains("resetCapture"),
            "camelCase field resetCapture: {json}"
        );
        assert!(
            json.contains("timeFormat"),
            "camelCase field timeFormat: {json}"
        );

        let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
        assert_eq!(profile, back);
    }

    #[test]
    fn rate_limit_pattern_omits_unset_optional_fields_in_json() {
        // reset_capture / time_format à None ⇒ leurs clés sont omises.
        let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
        let json = serde_json::to_string(&pattern).expect("serialise");
        assert!(
            json.contains("\"pattern\""),
            "pattern field present: {json}"
        );
        assert!(
            !json.contains("resetCapture"),
            "an unset resetCapture must be omitted; got: {json}"
        );
        assert!(
            !json.contains("timeFormat"),
            "an unset timeFormat must be omitted; got: {json}"
        );
    }
}
