//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by
//! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9).
//!
//! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex,
//! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`]
//! data, never in code. Adding an AI = adding a profile (the **Open/Closed**
//! principle made literal).
//!
//! # Responsibilities
//!
//! - [`detect`](AgentRuntime::detect): run the profile's detection command via
//!   the injected [`ProcessSpawner`] and report whether the CLI is installed
//!   (exit code 0). When `profile.detect` is `None`, fall back to
//!   `<command> --version` (see [`detection_spec`]).
//! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure**
//!   function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved
//!   `cwd` (template substitution), and the context-injection plan derived from
//!   the profile's [`ContextInjection`]. This is the testable core of L5.

use std::{sync::Arc, time::Duration};

use async_trait::async_trait;

use domain::ports::{
    AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
    SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter};
use domain::project::ProjectPath;

const DETECTION_TIMEOUT: Duration = Duration::from_millis(800);

/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
#[derive(Clone)]
pub struct CliAgentRuntime {
    spawner: Arc<dyn ProcessSpawner>,
}

impl CliAgentRuntime {
    /// Builds the runtime from an injected [`ProcessSpawner`] (the composition
    /// root passes a [`crate::LocalProcessSpawner`], or a remote one later).
    #[must_use]
    pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
        Self { spawner }
    }

    /// Builds the [`SpawnSpec`] used to *detect* a profile's CLI.
    ///
    /// - If `profile.detect` is set, it is parsed as a whitespace-delimited
    ///   command line (`"claude --version"` → command `claude`, args
    ///   `["--version"]`). The first token is the executable.
    /// - Otherwise we fall back to `<command> --version`, the near-universal
    ///   "is it installed?" probe.
    ///
    /// Detection runs in the current working directory and injects nothing.
    ///
    /// This is pure (no I/O) and therefore unit-testable without a spawner.
    ///
    /// # Errors
    /// [`RuntimeError::Detection`] if the detection command is blank.
    pub fn detection_spec(profile: &AgentProfile) -> Result<SpawnSpec, RuntimeError> {
        let line = profile
            .detect
            .clone()
            .unwrap_or_else(|| format!("{} --version", profile.command));
        let mut tokens = line.split_whitespace();
        let command = tokens
            .next()
            .ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))?
            .to_owned();
        let args = tokens.map(str::to_owned).collect();
        // Detection runs in a neutral cwd; "." is a safe relative placeholder the
        // spawner resolves against the process cwd.
        let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
        Ok(SpawnSpec {
            command,
            args,
            cwd,
            env: Vec::new(),
            context_plan: None,
            sandbox: None,
        })
    }

    /// Resolves the profile's `cwd_template` against the supplied `base` cwd.
    ///
    /// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
    /// already computed (and created) by `LaunchAgent` and passed as the `cwd`
    /// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The
    /// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy
    /// `{projectRoot}` is still substituted with the same base for backwards
    /// compatibility (the caller always passes the run dir now). An empty template
    /// defaults to the base itself.
    fn resolve_cwd(
        profile: &AgentProfile,
        base: &ProjectPath,
    ) -> Result<ProjectPath, RuntimeError> {
        let template = profile.cwd_template.trim();
        if template.is_empty() {
            return Ok(base.clone());
        }
        let resolved = template
            .replace("{agentRunDir}", base.as_str())
            .replace("{projectRoot}", base.as_str());
        ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
    }

    /// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and
    /// the prepared context. **Pure** — the testable heart of L5.
    ///
    /// | `ContextInjection`        | `ContextInjectionPlan`                                   |
    /// |---------------------------|----------------------------------------------------------|
    /// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there        |
    /// | `Flag { flag }`           | `Args { args }` — `{path}` substituted with the ctx path |
    /// | `Stdin`                   | `Stdin` — content piped on stdin                         |
    /// | `Env { var }`             | `Env { var }` — content/path delivered via env var       |
    ///
    /// For `Flag`, the flag template may contain `{path}` (replaced by the
    /// context's relative path). A flag *without* `{path}` is treated as a
    /// standalone switch followed by the path as a separate argument
    /// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
    /// whitespace after substitution (e.g. `--context-file {path}` →
    /// `["--context-file", "<path>"]`).
    fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
        match injection {
            ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
                target: target.clone(),
            },
            ContextInjection::Flag { flag } => {
                let path = &ctx.relative_path;
                let args = if flag.contains("{path}") {
                    flag.replace("{path}", path)
                        .split_whitespace()
                        .map(str::to_owned)
                        .collect()
                } else {
                    vec![flag.clone(), path.clone()]
                };
                ContextInjectionPlan::Args { args }
            }
            ContextInjection::Stdin => ContextInjectionPlan::Stdin,
            ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
        }
    }

    /// Composes the session resume/assign arguments for a launch. **Pure** — the
    /// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan).
    ///
    /// The profile's optional [`SessionStrategy`] crossed with the per-launch
    /// [`SessionPlan`] yields the args to append (exhaustive truth table):
    ///
    /// | `profile.session`                      | `SessionPlan`  | Args added |
    /// |----------------------------------------|----------------|------------|
    /// | `None`                                 | any            | `[]`       |
    /// | `Some{assign_flag: Some(f), ..}`       | `Assign{id}`   | `[f, id]`  |
    /// | `Some{resume_flag: r, ..}`             | `Resume{id}`   | `[r, id]`  |
    /// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) |
    /// | `Some{..}`                             | `None`         | `[]`       |
    /// | `Some{assign_flag: None, ..}`          | `Assign{id}`   | `[]` (no flag) |
    fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec<String> {
        let Some(strategy) = session else {
            return Vec::new();
        };
        match plan {
            SessionPlan::None => Vec::new(),
            SessionPlan::Assign { conversation_id } => match &strategy.assign_flag {
                Some(flag) => vec![flag.clone(), conversation_id.clone()],
                None => Vec::new(),
            },
            SessionPlan::Resume { conversation_id } => {
                let mut args = vec![strategy.resume_flag.clone()];
                if strategy.assign_flag.is_some() {
                    args.push(conversation_id.clone());
                }
                args
            }
        }
    }
}

#[async_trait]
impl AgentRuntime for CliAgentRuntime {
    async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
        if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) {
            return Ok(Self::detect_openai_compatible(profile).await);
        }

        let spec = Self::detection_spec(profile)?;
        let spawner = Arc::clone(&self.spawner);
        let output = tokio::time::timeout(DETECTION_TIMEOUT, spawner.run(spec))
            .await
            .map_err(|_| RuntimeError::Detection("detection timed out".to_owned()))?
            .map_err(|e| RuntimeError::Detection(e.to_string()))?;
        Ok(output.status.code == Some(0))
    }

    fn prepare_invocation(
        &self,
        profile: &AgentProfile,
        ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
        let plan = Self::injection_plan(&profile.context_injection, ctx);

        // For the `Flag` strategy the context path travels *on the command line*,
        // so fold those args into the spec's args (after the profile's static
        // args). The other strategies leave args untouched; the plan tells the
        // launcher (L6) what else to do (write file / pipe stdin / set env).
        let mut args = profile.args.clone();
        if let ContextInjectionPlan::Args { args: extra } = &plan {
            args.extend(extra.iter().cloned());
        }

        // Session resume/assign args come *after* the static + context-injection
        // args, so re-opening a conversation never disturbs context delivery.
        args.extend(Self::session_args(profile.session.as_ref(), session));

        Ok(SpawnSpec {
            command: profile.command.clone(),
            args,
            cwd: resolved_cwd,
            env: Vec::new(),
            context_plan: Some(plan),
            sandbox: None,
        })
    }
}

impl CliAgentRuntime {
    async fn detect_openai_compatible(profile: &AgentProfile) -> bool {
        let Some(chat) = &profile.chat_http else {
            return false;
        };
        let url = openai_compatible_models_endpoint(&chat.endpoint);
        let probe = async move {
            let client = reqwest::Client::builder()
                .timeout(DETECTION_TIMEOUT)
                .build()
                .ok()?;
            let response = client.get(url).send().await.ok()?;
            Some(response.status().is_success())
        };
        tokio::time::timeout(DETECTION_TIMEOUT, probe)
            .await
            .ok()
            .flatten()
            .unwrap_or(false)
    }
}

fn openai_compatible_models_endpoint(endpoint: &str) -> String {
    let trimmed = endpoint.trim_end_matches('/');
    let base = trimmed
        .strip_suffix("/chat/completions")
        .unwrap_or(trimmed)
        .trim_end_matches('/');
    format!("{base}/models")
}
