//! [`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 observé/cadré : `step_start`, `text(part.text)`, `tool_use`,
//! `step_finish`, `error`.

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

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),
    /// Activité outil OpenCode.
    ToolActivity(String),
    /// Fin d'étape OpenCode.
    StepFinish,
    /// Erreur terminale remontée par OpenCode.
    Error(String),
    /// Evénement JSON valide mais hors contrat minimal.
    Ignored,
}

/// Identifiant natif de session OpenCode exposé dans les événements JSONL.
pub fn extract_session_id(line: &str) -> Result<Option<String>, AgentSessionError> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    let value: Value = serde_json::from_str(trimmed)
        .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
    Ok(
        json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
            value
                .get("part")
                .and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
        }),
    )
}

/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable";

/// 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> {
    parse_jsonl_record(line).map(|record| record.event)
}

#[derive(Debug, Clone)]
struct ParsedRecord {
    event: ParsedEvent,
    session_id: Option<String>,
    message_id: Option<String>,
}

fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return Ok(ParsedRecord {
            event: ParsedEvent::Ignored,
            session_id: None,
            message_id: None,
        });
    }
    let value: Value = serde_json::from_str(trimmed)
        .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
    let part = value.get("part");
    let session_id =
        json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
            part.and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
        });
    let message_id =
        json_string_field(&value, &["messageID", "message_id", "messageId"]).or_else(|| {
            part.and_then(|part| json_string_field(part, &["messageID", "message_id", "messageId"]))
        });
    let event = match value.get("type").and_then(Value::as_str).or_else(|| {
        part.and_then(|part| part.get("type"))
            .and_then(Value::as_str)
    }) {
        Some("step_start" | "step.start" | "step-start") => ParsedEvent::StepStart,
        Some("step_finish" | "step.finish" | "step-finish") => ParsedEvent::StepFinish,
        // Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ;
        // un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer.
        Some("text") => part
            .and_then(|part| part.get("text"))
            .or_else(|| value.get("text"))
            .or_else(|| value.get("content"))
            .and_then(Value::as_str)
            .map(|text| ParsedEvent::Text(text.to_owned()))
            .unwrap_or(ParsedEvent::Ignored),
        Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity(tool_label(&value)),
        Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)),
        _ => ParsedEvent::Ignored,
    };
    Ok(ParsedRecord {
        event,
        session_id,
        message_id,
    })
}

fn json_string_field(value: &Value, names: &[&str]) -> Option<String> {
    names
        .iter()
        .find_map(|name| value.get(*name).and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(str::to_owned)
}

/// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une
/// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement).
fn opencode_error_message(value: &Value) -> String {
    let error = value
        .get("error")
        .or_else(|| value.get("properties").and_then(|props| props.get("error")));
    error
        .and_then(Value::as_str)
        .map(str::to_owned)
        .or_else(|| {
            error
                .and_then(|e| e.get("message"))
                .and_then(Value::as_str)
                .map(str::to_owned)
        })
        .or_else(|| {
            error
                .and_then(|e| e.get("data"))
                .and_then(|data| data.get("message"))
                .and_then(Value::as_str)
                .map(str::to_owned)
        })
        .or_else(|| {
            error
                .and_then(|e| e.get("name"))
                .and_then(Value::as_str)
                .map(str::to_owned)
        })
        .unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned())
}

fn tool_label(value: &Value) -> String {
    let part = value.get("part").unwrap_or(value);
    json_string_field(part, &["tool", "name"])
        .or_else(|| json_string_field(value, &["tool", "name"]))
        .unwrap_or_else(|| "outil OpenCode".to_owned())
}

/// Convertit des lignes JSONL OpenCode en événements domaine.
///
/// Le `Final` est la concaténation ordonnée des événements `text` du tour courant.
/// Si OpenCode rejoue des événements d'une même session, les identifiants de
/// message (`messageID`/`messageId`) servent à conserver seulement le dernier
/// message assistant observable. Un tour terminé sans texte exploitable est une
/// erreur de décodage, pas un succès vide.
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
    parse_jsonl_turn_scoped(lines, None)
}

fn parse_jsonl_turn_scoped(
    lines: &[String],
    expected_session_id: Option<&str>,
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
    let records = current_turn_records(lines, expected_session_id)?;
    let mut events = Vec::new();
    let mut final_text = String::new();
    let mut error_seen = false;
    for record in records {
        match record.event {
            ParsedEvent::StepStart => {
                events.push(ReplyEvent::Heartbeat);
            }
            ParsedEvent::StepFinish => {
                events.push(ReplyEvent::Heartbeat);
            }
            ParsedEvent::ToolActivity(label) => {
                events.push(ReplyEvent::ToolActivity { label });
            }
            ParsedEvent::Text(text) => {
                if !text.is_empty() {
                    final_text.push_str(&text);
                    events.push(ReplyEvent::TextDelta { text });
                }
            }
            ParsedEvent::Error(message) => {
                error_seen = true;
                events.push(ReplyEvent::Error { message });
            }
            ParsedEvent::Ignored => {}
        }
    }
    if final_text.trim().is_empty() {
        if error_seen {
            return Ok(events);
        }
        return Err(AgentSessionError::Decode(
            "OpenCode n'a produit aucun final textuel exploitable".to_owned(),
        ));
    }
    events.push(ReplyEvent::Final {
        content: final_text,
    });
    Ok(events)
}

fn current_turn_records(
    lines: &[String],
    expected_session_id: Option<&str>,
) -> Result<Vec<ParsedRecord>, AgentSessionError> {
    let mut records = Vec::new();
    for line in lines {
        let record = parse_jsonl_record(line)?;
        if let (Some(expected), Some(actual)) = (expected_session_id, record.session_id.as_deref())
        {
            if actual != expected {
                continue;
            }
        }
        if !matches!(record.event, ParsedEvent::Ignored) {
            records.push(record);
        }
    }
    let Some(current_message_id) = records.iter().rev().find_map(|record| {
        matches!(
            record.event,
            ParsedEvent::Text(_)
                | ParsedEvent::ToolActivity(_)
                | ParsedEvent::StepStart
                | ParsedEvent::StepFinish
                | ParsedEvent::Error(_)
        )
        .then(|| record.message_id.as_deref())
        .flatten()
        .map(str::to_owned)
    }) else {
        return Ok(records);
    };
    Ok(records
        .into_iter()
        .filter(|record| {
            record
                .message_id
                .as_deref()
                .is_none_or(|message_id| message_id == current_message_id.as_str())
        })
        .collect())
}

fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool {
    events
        .iter()
        .any(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }))
}

/// 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)>,
    engine_session_id: Mutex<Option<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>,
        seed: Option<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,
            engine_session_id: Mutex::new(seed),
            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()]);
        if let Some(engine_id) = self.conversation_id() {
            args.extend(["--session".to_owned(), engine_id]);
        }
        args.push(prompt.to_owned());
        args
    }

    fn capture_session_id(&self, engine_id: String) {
        *self
            .engine_session_id
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(engine_id);
    }
}

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

    fn conversation_id(&self) -> Option<String> {
        self.engine_session_id
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone()
    }

    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 expected_session = self.conversation_id();
        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()))?
        {
            if let Some(engine_id) = extract_session_id(&line)? {
                if expected_session
                    .as_deref()
                    .is_none_or(|expected| expected == engine_id)
                {
                    self.capture_session_id(engine_id);
                }
            }
            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);
        let parsed_session = expected_session.or_else(|| self.conversation_id());
        let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref());
        if !status.success() {
            if let Ok(events) = parsed {
                if has_structured_terminal_event(&events) {
                    return Ok(Box::new(events.into_iter()));
                }
            }
            if stderr.contains("server unavailable") && stderr.contains("key=idea") {
                return Err(AgentSessionError::Start(
                    "serveur MCP OpenCode `idea` indisponible".to_owned(),
                ));
            }
            return Err(AgentSessionError::Io(format!(
                "OpenCode a quitté avec le statut {}: {}",
                status,
                stderr.trim()
            )));
        }
        if stderr.contains("server unavailable") && stderr.contains("key=idea") {
            return Err(AgentSessionError::Start(
                "serveur MCP OpenCode `idea` indisponible".to_owned(),
            ));
        }

        let events = parsed?;
        Ok(Box::new(events.into_iter()))
    }

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

#[cfg(test)]
mod tests {
    #[cfg(unix)]
    use std::fs;
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;
    #[cfg(unix)]
    use std::path::{Path, PathBuf};

    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","part":{"text":"hel"}}"#.to_owned(),
            r#"{"type":"text","part":{"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_turn_rejects_tool_only_finished_turn() {
        let err = parse_jsonl_turn(&[
            r#"{"type":"step_start"}"#.to_owned(),
            r#"{"type":"step_finish"}"#.to_owned(),
        ])
        .unwrap_err();
        assert!(
            matches!(err, AgentSessionError::Decode(ref message) if message.contains("aucun final textuel exploitable")),
            "tool-only turn must be actionable, not a fake empty success: {err:?}"
        );
    }

    #[test]
    fn parse_jsonl_turn_rejects_truly_empty_or_ignored_turn() {
        let empty = parse_jsonl_turn(&[]).unwrap_err();
        assert!(matches!(empty, AgentSessionError::Decode(_)));
        let ignored =
            parse_jsonl_turn(&[r#"{"type":"session","id":"s1"}"#.to_owned()]).unwrap_err();
        assert!(matches!(ignored, AgentSessionError::Decode(_)));
    }

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

    /// Format JSONL réel de `opencode run --format json`, confirmé par lecture du
    /// source `packages/opencode/src/cli/cmd/run.ts` : le texte est niché sous
    /// `.part.text`, pas à la racine.
    fn opencode_script() -> Vec<&'static str> {
        vec![
            r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#,
            r#"{"type":"text","timestamp":2,"sessionID":"s1","part":{"id":"p1","type":"text","text":"réponse réelle","time":{"start":1,"end":2}}}"#,
            r#"{"type":"step_finish","timestamp":3,"sessionID":"s1"}"#,
        ]
    }

    #[test]
    fn parse_jsonl_turn_reads_text_nested_under_part() {
        let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect();
        let events = parse_jsonl_turn(&lines).unwrap();
        assert_eq!(
            events,
            vec![
                ReplyEvent::Heartbeat,
                ReplyEvent::TextDelta {
                    text: "réponse réelle".to_owned()
                },
                ReplyEvent::Heartbeat,
                ReplyEvent::Final {
                    content: "réponse réelle".to_owned()
                }
            ]
        );
    }

    #[test]
    fn parse_jsonl_turn_emits_error_event() {
        let events = parse_jsonl_turn(&[
            r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#.to_owned(),
            r#"{"type":"error","timestamp":2,"sessionID":"s1","error":{"message":"quelque chose a échoué"}}"#
                .to_owned(),
        ])
        .unwrap();
        assert_eq!(
            events,
            vec![
                ReplyEvent::Heartbeat,
                ReplyEvent::Error {
                    message: "quelque chose a échoué".to_owned()
                }
            ]
        );
    }

    #[test]
    fn parse_jsonl_event_error_accepts_raw_string() {
        let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
        assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
    }

    #[test]
    fn parse_jsonl_event_accepts_tool_use() {
        let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap();
        assert_eq!(event, ParsedEvent::ToolActivity("bash".to_owned()));
    }

    #[test]
    fn parse_jsonl_turn_keeps_latest_message_when_opencode_replays_history() {
        let events = parse_jsonl_turn(&[
            r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"old","type":"step-start"}}"#
                .to_owned(),
            r#"{"type":"text","sessionID":"s1","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}"#
                .to_owned(),
            r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"old","type":"step-finish"}}"#
                .to_owned(),
            r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"new","type":"step-start"}}"#
                .to_owned(),
            r#"{"type":"text","sessionID":"s1","part":{"messageID":"new","type":"text","text":"réponse courante"}}"#
                .to_owned(),
            r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"new","type":"step-finish"}}"#
                .to_owned(),
        ])
        .unwrap();
        assert_eq!(
            events.last(),
            Some(&ReplyEvent::Final {
                content: "réponse courante".to_owned()
            })
        );
        assert!(
            !events.iter().any(|event| matches!(
                event,
                ReplyEvent::TextDelta { text } if text.contains("ancienne")
            )),
            "replayed text from a previous message must not pollute the current final: {events:?}"
        );
    }

    #[test]
    fn parse_jsonl_turn_scoped_ignores_other_sessions_when_known() {
        let events = parse_jsonl_turn_scoped(
            &[
                r#"{"type":"text","sessionID":"other","part":{"messageID":"m1","text":"pollution"}}"#
                    .to_owned(),
                r#"{"type":"text","sessionID":"current","part":{"messageID":"m2","text":"ok"}}"#
                    .to_owned(),
            ],
            Some("current"),
        )
        .unwrap();
        assert_eq!(
            events.last(),
            Some(&ReplyEvent::Final {
                content: "ok".to_owned()
            })
        );
    }

    #[cfg(unix)]
    struct TempDir(PathBuf);

    #[cfg(unix)]
    impl TempDir {
        fn new(label: &str) -> Self {
            let path = std::env::temp_dir()
                .join(format!("idea-opencode-{label}-{}", uuid::Uuid::new_v4()));
            fs::create_dir_all(&path).unwrap();
            Self(path)
        }

        fn path(&self) -> &Path {
            &self.0
        }
    }

    #[cfg(unix)]
    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn send_prefers_parseable_stdout_over_nonzero_exit_status() {
        let tmp = TempDir::new("exit-one");
        let script = tmp.path().join("opencode-fixture.sh");
        fs::write(
            &script,
            r#"#!/bin/sh
printf '%s\n' '{"type":"step_start"}'
printf '%s\n' '{"type":"text","part":{"text":"résultat exploitable"}}'
printf '%s\n' '{"type":"step_finish"}'
printf '%s\n' 'stderr générique' >&2
exit 1
"#,
        )
        .unwrap();
        let mut permissions = fs::metadata(&script).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&script, permissions).unwrap();
        let session = OpenCodeSession::new(
            SessionId::new_random(),
            script.to_string_lossy(),
            Vec::new(),
            tmp.path().to_string_lossy(),
            None,
            Vec::new(),
            None,
            None,
        )
        .unwrap();

        let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();

        assert_eq!(
            events,
            vec![
                ReplyEvent::Heartbeat,
                ReplyEvent::TextDelta {
                    text: "résultat exploitable".to_owned()
                },
                ReplyEvent::Heartbeat,
                ReplyEvent::Final {
                    content: "résultat exploitable".to_owned()
                }
            ]
        );
    }

    #[test]
    fn extract_session_id_accepts_opencode_jsonl_session_id_variants() {
        assert_eq!(
            extract_session_id(r#"{"type":"step_start","sessionID":"s1"}"#).unwrap(),
            Some("s1".to_owned())
        );
        assert_eq!(
            extract_session_id(r#"{"type":"step_start","session_id":"s2"}"#).unwrap(),
            Some("s2".to_owned())
        );
        assert_eq!(
            extract_session_id(r#"{"type":"step_start","sessionId":"s3"}"#).unwrap(),
            Some("s3".to_owned())
        );
        assert_eq!(
            extract_session_id(r#"{"type":"step_start","sessionID":"   "}"#).unwrap(),
            None
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn send_captures_engine_session_id_from_jsonl() {
        let tmp = TempDir::new("capture-session");
        let script = tmp.path().join("opencode-fixture.sh");
        fs::write(
            &script,
            r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}'
printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
"#,
        )
        .unwrap();
        let mut permissions = fs::metadata(&script).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&script, permissions).unwrap();
        let session = OpenCodeSession::new(
            SessionId::new_random(),
            script.to_string_lossy(),
            Vec::new(),
            tmp.path().to_string_lossy(),
            None,
            Vec::new(),
            None,
            None,
        )
        .unwrap();

        assert_eq!(session.conversation_id(), None);
        let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();

        assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
        assert_eq!(
            session.conversation_id().as_deref(),
            Some("opencode-engine-1")
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn seeded_or_captured_session_id_is_passed_as_opencode_session_arg() {
        let tmp = TempDir::new("resume-arg");
        let script = tmp.path().join("opencode-fixture.sh");
        let args_file = tmp.path().join("args.txt");
        fs::write(
            &script,
            format!(
                r#"#!/bin/sh
printf '%s\n' "$@" > '{}'
printf '%s\n' '{{"type":"step_start","sessionID":"seeded-engine"}}'
printf '%s\n' '{{"type":"text","sessionID":"seeded-engine","part":{{"text":"ok"}}}}'
printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
"#,
                args_file.display()
            ),
        )
        .unwrap();
        let mut permissions = fs::metadata(&script).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&script, permissions).unwrap();
        let session = OpenCodeSession::new(
            SessionId::new_random(),
            script.to_string_lossy(),
            Vec::new(),
            tmp.path().to_string_lossy(),
            Some("seeded-engine".to_owned()),
            Vec::new(),
            None,
            None,
        )
        .unwrap();

        session.send("prompt").await.unwrap().for_each(drop);

        let args = fs::read_to_string(args_file).unwrap();
        let argv: Vec<&str> = args.lines().collect();
        assert!(
            argv.windows(2)
                .any(|pair| pair == ["--session", "seeded-engine"]),
            "OpenCode resume must use `--session <engine id>`, got: {argv:?}"
        );
        assert_eq!(argv.last(), Some(&"prompt"));
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn seeded_session_ignores_and_does_not_capture_other_session_events() {
        let tmp = TempDir::new("ignore-other-session");
        let script = tmp.path().join("opencode-fixture.sh");
        fs::write(
            &script,
            r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"m1","text":"réponse seed"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}'
"#,
        )
        .unwrap();
        let mut permissions = fs::metadata(&script).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&script, permissions).unwrap();
        let session = OpenCodeSession::new(
            SessionId::new_random(),
            script.to_string_lossy(),
            Vec::new(),
            tmp.path().to_string_lossy(),
            Some("seeded-engine".to_owned()),
            Vec::new(),
            None,
            None,
        )
        .unwrap();

        let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();

        assert_eq!(
            events.last(),
            Some(&ReplyEvent::Final {
                content: "réponse seed".to_owned()
            })
        );
        assert_eq!(session.conversation_id().as_deref(), Some("seeded-engine"));
    }
}
