//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp.
//!
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
//! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat
//! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`.

use std::process::Stdio;
use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;

use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;

/// Un événement OpenCode parsé depuis stdout JSONL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedEvent {
    /// Début d'étape OpenCode.
    StepStart,
    /// Fragment texte assistant.
    Text(String),
    /// Fin d'étape OpenCode.
    StepFinish,
    /// Evénement JSON valide mais hors contrat minimal.
    Ignored,
}

/// Parse une ligne JSONL OpenCode.
///
/// # Errors
/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide.
pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return Ok(ParsedEvent::Ignored);
    }
    let value: Value = serde_json::from_str(trimmed)
        .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
    match value.get("type").and_then(Value::as_str) {
        Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
        Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
        Some("text") => Ok(ParsedEvent::Text(
            value
                .get("text")
                .or_else(|| value.get("content"))
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_owned(),
        )),
        _ => Ok(ParsedEvent::Ignored),
    }
}

/// Convertit des lignes JSONL OpenCode en événements domaine.
///
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
/// valide mais sans texte est une erreur typée : une délégation ne peut pas être
/// considérée comme réussie sans réponse finale capturable.
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
    let mut events = Vec::new();
    let mut final_text = String::new();
    for line in lines {
        match parse_jsonl_event(line)? {
            ParsedEvent::StepStart | ParsedEvent::StepFinish => {
                events.push(ReplyEvent::Heartbeat);
            }
            ParsedEvent::Text(text) => {
                if !text.is_empty() {
                    final_text.push_str(&text);
                    events.push(ReplyEvent::TextDelta { text });
                }
            }
            ParsedEvent::Ignored => {}
        }
    }
    if final_text.trim().is_empty() {
        return Err(AgentSessionError::Decode(
            "OpenCode n'a produit aucun final textuel".to_owned(),
        ));
    }
    events.push(ReplyEvent::Final {
        content: final_text,
    });
    Ok(events)
}

/// Découpe une commande utilisateur en argv sans shell implicite.
///
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
/// pipes et substitutions ne sont pas interprétés.
pub fn split_command_prefix(raw: &str) -> Result<Vec<String>, AgentSessionError> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut chars = raw.chars().peekable();
    let mut quote: Option<char> = None;
    while let Some(ch) = chars.next() {
        match (quote, ch) {
            (Some(q), c) if c == q => quote = None,
            (None, '\'' | '"') => quote = Some(ch),
            (_, '\\') => {
                if let Some(next) = chars.next() {
                    cur.push(next);
                } else {
                    cur.push('\\');
                }
            }
            (None, c) if c.is_whitespace() => {
                if !cur.is_empty() {
                    out.push(std::mem::take(&mut cur));
                }
            }
            (_, c) => cur.push(c),
        }
    }
    if quote.is_some() {
        return Err(AgentSessionError::Start(
            "commande OpenCode invalide: guillemet non fermé".to_owned(),
        ));
    }
    if !cur.is_empty() {
        out.push(cur);
    }
    if out.is_empty() {
        return Err(AgentSessionError::Start(
            "commande OpenCode vide".to_owned(),
        ));
    }
    Ok(out)
}

/// Adapter OpenCode process-backed.
pub struct OpenCodeSession {
    id: SessionId,
    command: String,
    prefix_args: Vec<String>,
    cwd: String,
    env: Vec<(String, String)>,
    sandbox: Option<SandboxPlan>,
    sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}

impl OpenCodeSession {
    /// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu,
    /// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`.
    pub fn new(
        id: SessionId,
        command_prefix: impl Into<String>,
        profile_args: Vec<String>,
        cwd: impl Into<String>,
        env: Vec<(String, String)>,
        sandbox: Option<SandboxPlan>,
        sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
    ) -> Result<Self, AgentSessionError> {
        let mut prefix = split_command_prefix(&command_prefix.into())?;
        let command = prefix.remove(0);
        prefix.extend(profile_args);
        Ok(Self {
            id,
            command,
            prefix_args: prefix,
            cwd: cwd.into(),
            env,
            sandbox,
            sandbox_enforcer,
        })
    }

    fn build_args(&self, prompt: &str) -> Vec<String> {
        let mut args = self.prefix_args.clone();
        args.extend([
            "run".to_owned(),
            "--format".to_owned(),
            "json".to_owned(),
            prompt.to_owned(),
        ]);
        args
    }
}

#[async_trait]
impl AgentSession for OpenCodeSession {
    fn id(&self) -> SessionId {
        self.id
    }

    fn conversation_id(&self) -> Option<String> {
        None
    }

    async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
        let mut cmd = Command::new(&self.command);
        cmd.args(self.build_args(prompt))
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if !self.cwd.is_empty() && self.cwd != "/" {
            cmd.current_dir(&self.cwd);
        }
        for (key, value) in &self.env {
            cmd.env(key, value);
        }
        // OpenCode supporte déjà son propre confinement logique; le plan Landlock
        // structuré reste réservé aux chemins process génériques existants.
        let _ = (&self.sandbox, &self.sandbox_enforcer);

        let mut child = cmd
            .spawn()
            .map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?;
        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
        let mut stderr_pipe = child
            .stderr
            .take()
            .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;

        let mut lines = BufReader::new(stdout).lines();
        let mut collected = Vec::new();
        while let Some(line) = lines
            .next_line()
            .await
            .map_err(|e| AgentSessionError::Io(e.to_string()))?
        {
            collected.push(line);
        }

        let mut stderr_bytes = Vec::new();
        stderr_pipe
            .read_to_end(&mut stderr_bytes)
            .await
            .map_err(|e| AgentSessionError::Io(e.to_string()))?;
        let status = child
            .wait()
            .await
            .map_err(|e| AgentSessionError::Io(e.to_string()))?;
        let stderr = String::from_utf8_lossy(&stderr_bytes);
        if stderr.contains("server unavailable") && stderr.contains("key=idea") {
            return Err(AgentSessionError::Start(
                "serveur MCP OpenCode `idea` indisponible".to_owned(),
            ));
        }
        if !status.success() {
            return Err(AgentSessionError::Io(format!(
                "OpenCode a quitté avec le statut {}: {}",
                status,
                stderr.trim()
            )));
        }

        let events = parse_jsonl_turn(&collected)?;
        Ok(Box::new(events.into_iter()))
    }

    async fn shutdown(&self) -> Result<(), AgentSessionError> {
        Ok(())
    }
}

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

    #[test]
    fn split_command_prefix_handles_quotes_without_shell() {
        assert_eq!(
            split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(),
            vec!["/tmp/my opencode", "--flag", "two words", "three words"]
        );
    }

    #[test]
    fn parse_jsonl_turn_concatenates_text_and_adds_final() {
        let events = parse_jsonl_turn(&[
            r#"{"type":"step_start"}"#.to_owned(),
            r#"{"type":"text","text":"hel"}"#.to_owned(),
            r#"{"type":"text","text":"lo"}"#.to_owned(),
            r#"{"type":"step_finish"}"#.to_owned(),
        ])
        .unwrap();
        assert_eq!(
            events,
            vec![
                ReplyEvent::Heartbeat,
                ReplyEvent::TextDelta {
                    text: "hel".to_owned()
                },
                ReplyEvent::TextDelta {
                    text: "lo".to_owned()
                },
                ReplyEvent::Heartbeat,
                ReplyEvent::Final {
                    content: "hello".to_owned()
                }
            ]
        );
    }

    #[test]
    fn parse_jsonl_turn_rejects_empty_final() {
        let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err();
        assert!(matches!(err, AgentSessionError::Decode(_)));
    }

    #[test]
    fn parse_jsonl_event_rejects_invalid_json() {
        let err = parse_jsonl_event("{nope").unwrap_err();
        assert!(matches!(err, AgentSessionError::Decode(_)));
    }
}
