//! [`StructuredSessionFactory`] — la fabrique [`AgentSessionFactory`] qui **route un
//! profil vers le bon adapter** structuré (ARCHITECTURE §17.2) selon
//! `profile.structured_adapter` (§17.3). Agrège Claude + Codex derrière une seule
//! surface ; aucun type concret ne franchit la frontière domaine (seuls
//! `Arc<dyn AgentSession>` sortent).
//!
//! Open/Closed : ajouter un moteur structuré = ajouter un adapter + une variante
//! [`StructuredAdapter`] + un bras de `match` ici. Le cœur ne bouge pas.

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;

use domain::ports::{
    AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
    ToolInvocationError, ToolInvoker, ToolSpec,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;

use super::claude::ClaudeSdkSession;
use super::codex::CodexExecSession;
use super::openai_compat::OpenAiCompatibleSession;
use super::opencode::OpenCodeSession;

const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester";

struct ProjectScopedToolInvoker {
    inner: Arc<dyn ToolInvoker>,
    project_root: String,
    requester: String,
}

#[async_trait]
impl ToolInvoker for ProjectScopedToolInvoker {
    fn tools(&self) -> Vec<ToolSpec> {
        self.inner.tools()
    }

    async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
        let mut value: Value = serde_json::from_str(args_json)
            .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
        match &mut value {
            Value::Object(map) => {
                map.insert(
                    PROJECT_ROOT_ARG.to_owned(),
                    Value::String(self.project_root.clone()),
                );
                map.insert(
                    REQUESTER_ARG.to_owned(),
                    Value::String(self.requester.clone()),
                );
            }
            _ => {
                return Err(ToolInvocationError::InvalidArguments(
                    "les arguments d'outil doivent être un objet JSON".to_owned(),
                ));
            }
        }
        self.inner.call(name, &value.to_string()).await
    }
}

/// Fabrique infra des sessions structurées, sélectionnée par le profil.
///
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
#[derive(Clone, Default)]
pub struct StructuredSessionFactory {
    /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
    /// sandboxing (chemin natif inchangé, zéro régression).
    sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
    /// Invoker outil optionnel pour les adapters sans client MCP natif.
    tool_invoker: Option<Arc<dyn ToolInvoker>>,
}

impl StructuredSessionFactory {
    /// Construit la fabrique (sans enforcer : chemin natif).
    #[must_use]
    pub fn new() -> Self {
        Self {
            sandbox_enforcer: None,
            tool_invoker: None,
        }
    }

    /// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
    /// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
    /// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
    /// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
    #[must_use]
    pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
        self.sandbox_enforcer = Some(enforcer);
        self
    }

    /// Builder additif : câble l'invocation d'outils `idea_*` pour les moteurs HTTP
    /// sans client MCP natif. `None` (défaut) ⇒ chat nu.
    #[must_use]
    pub fn with_tool_invoker(mut self, invoker: Arc<dyn ToolInvoker>) -> Self {
        self.tool_invoker = Some(invoker);
        self
    }
}

/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
/// amorce l'adapter avec un id de conversation existant ; `Assign`/`None` partent
/// d'une conversation neuve (l'id sera capté au premier tour).
fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
    match session {
        SessionPlan::Resume { conversation_id } => Some(conversation_id.clone()),
        SessionPlan::None | SessionPlan::Assign { .. } => None,
    }
}

#[async_trait]
impl AgentSessionFactory for StructuredSessionFactory {
    fn supports(&self, profile: &AgentProfile) -> bool {
        // Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en
        // structuré. `is_selectable` et `supports` partagent ainsi le **même**
        // prédicat de domaine, ils ne peuvent pas diverger.
        profile.is_selectable()
    }

    async fn start(
        &self,
        profile: &AgentProfile,
        ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
        requester: Option<&str>,
        env: &[(String, String)],
        sandbox: Option<&SandboxPlan>,
    ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
        let adapter = profile.structured_adapter.ok_or_else(|| {
            AgentSessionError::Start(format!(
                "le profil « {} » n'a pas d'adapter structuré",
                profile.name
            ))
        })?;

        let id = SessionId::new_random();
        let command = profile.command.clone();
        let cwd = cwd.as_str().to_owned();
        let seed = seed_conversation_id(session);
        let requester = requester
            .map(str::to_owned)
            .unwrap_or_else(|| fallback_requester_from_cwd(&cwd));

        // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
        // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
        // `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
        // l'adapter reste sur le drain async natif.
        let plan = sandbox.cloned();
        let enforcer = self.sandbox_enforcer.clone();

        // NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
        // convention file dans le run dir *avant* l'appel à la factory (le `.md` est
        // déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
        // injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
        // fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
        let session: Arc<dyn AgentSession> = match adapter {
            StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
                id, command, cwd, seed, plan, enforcer,
            )),
            StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
                id,
                command,
                cwd,
                seed,
                vec![ctx.project_root.clone()],
                env.to_vec(),
                plan,
                enforcer,
            )),
            StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
                id,
                profile.command.clone(),
                profile.args.clone(),
                cwd,
                env.to_vec(),
                plan,
                enforcer,
            )?),
            StructuredAdapter::OpenAiCompatible => {
                let config = profile.chat_http.clone().ok_or_else(|| {
                    AgentSessionError::Start(format!(
                        "le profil « {} » n'a pas de configuration chatHttp",
                        profile.name
                    ))
                })?;
                Arc::new(OpenAiCompatibleSession::new(
                    id,
                    config,
                    &cwd,
                    ctx.content.as_str(),
                    self.tool_invoker.clone().map(|inner| {
                        Arc::new(ProjectScopedToolInvoker {
                            inner,
                            project_root: ctx.project_root.clone(),
                            requester: requester.clone(),
                        }) as Arc<dyn ToolInvoker>
                    }),
                )?)
            }
        };
        Ok(session)
    }
}

fn fallback_requester_from_cwd(cwd: &str) -> String {
    std::path::Path::new(cwd)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or_default()
        .to_owned()
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use serde_json::json;

    use super::*;

    #[derive(Default)]
    struct RecordingToolInvoker {
        call: Mutex<Option<(String, String)>>,
    }

    #[async_trait]
    impl ToolInvoker for RecordingToolInvoker {
        fn tools(&self) -> Vec<ToolSpec> {
            Vec::new()
        }

        async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
            *self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned()));
            Ok("ok".to_owned())
        }
    }

    #[tokio::test]
    async fn project_scoped_tool_invoker_injects_explicit_requester_identity() {
        let recorder = Arc::new(RecordingToolInvoker::default());
        let invoker = ProjectScopedToolInvoker {
            inner: recorder.clone(),
            project_root: "/project/root".to_owned(),
            requester: "ticket-assistant:project:7".to_owned(),
        };

        let out = invoker
            .call("idea_ticket_update", r##"{"ref":"#7"}"##)
            .await
            .expect("tool call ok");

        assert_eq!(out, "ok");
        let (name, args_json) = recorder.call.lock().unwrap().clone().expect("recorded");
        assert_eq!(name, "idea_ticket_update");
        let args: Value = serde_json::from_str(&args_json).unwrap();
        assert_eq!(
            args,
            json!({
                "ref": "#7",
                PROJECT_ROOT_ARG: "/project/root",
                REQUESTER_ARG: "ticket-assistant:project:7",
            })
        );
    }

    #[test]
    fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() {
        assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7");
    }
}
