//! Fake CLI scriptable + **harnais de conformité de port (Liskov)** pour les
//! adapters structurés (ARCHITECTURE §17.2). Permet de tester la **machinerie**
//! (spawn, lecture ligne-à-ligne, drain jusqu'au `Final`, capture d'id de session,
//! shutdown, timeout) **sans réseau ni vraie CLI**, et d'asserter le **contrat
//! [`AgentSession`]** de façon réutilisable pour Claude ET Codex.
//!
//! Disponible hors `cfg(test)` (mais sous une porte `pub`) pour que QA puisse
//! réutiliser le harnais et le fake CLI dans des tests d'intégration ultérieurs.
//!
//! > Les *scripts* de lignes JSON fournis aux tests reproduisent le **format RÉEL
//! > vérifié 2026-06-09** (spikes S1/S2 résolus), documenté dans `claude::parse_event`
//! > / `codex::parse_event`. La machinerie et le harnais sont indépendants du format.

use std::io::Write;
use std::path::PathBuf;

use super::process::SpawnLine;

/// Un **fake CLI** : un script exécutable qui **rejoue un script de lignes** sur
/// stdout (en ignorant ses arguments), puis se termine. Substitué au vrai
/// `claude`/`codex` pour rendre la machinerie déterministe et hors-réseau.
///
/// Le binaire est matérialisé dans un fichier temporaire ; il est supprimé au drop.
pub struct FakeCli {
    /// Chemin du script exécutable généré.
    path: PathBuf,
}

impl FakeCli {
    /// Crée un fake CLI qui imprimera exactement `lines` (une par ligne de stdout),
    /// dans l'ordre, puis sortira avec le code 0.
    ///
    /// # Panics
    /// Panique si le fichier temporaire ne peut être écrit (environnement de test
    /// cassé) — acceptable dans un utilitaire de test.
    #[must_use]
    pub fn printing(lines: &[&str]) -> Self {
        let mut path = std::env::temp_dir();
        // Nom unique : pid + compteur atomique pour éviter toute collision entre
        // tests parallèles.
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        path.push(format!("idea-fake-cli-{}-{n}", std::process::id()));

        let mut script = String::from("#!/bin/sh\n");
        for line in lines {
            // `printf '%s\n'` imprime la ligne littéralement (pas d'interprétation
            // d'échappements), en isolant la donnée de toute injection shell via
            // l'unique argument `--`.
            script.push_str("printf '%s\\n' ");
            script.push_str(&shell_single_quote(line));
            script.push('\n');
        }

        let mut file = std::fs::File::create(&path).expect("création du fake CLI");
        file.write_all(script.as_bytes())
            .expect("écriture du fake CLI");
        // `sync_all` force la fermeture/flush du descripteur en écriture AVANT toute
        // tentative d'exécution : sans cela, `execve` sur un binaire encore ouvert en
        // écriture par un autre thread (suite parallèle) retourne `ETXTBSY`
        // (« Text file busy », os error 26) — défaut de fixture intermittent.
        file.sync_all().expect("sync du fake CLI");
        drop(file);
        set_executable(&path);
        wait_until_executable(&path);

        Self { path }
    }

    /// Le binaire à passer en `command` d'un [`SpawnLine`] / d'un adapter.
    #[must_use]
    pub fn command(&self) -> String {
        self.path.to_string_lossy().into_owned()
    }

    /// Construit un [`SpawnLine`] minimal lançant ce fake CLI (utile pour tester la
    /// machinerie [`super::process::run_turn`] directement).
    #[must_use]
    pub fn spawn_line(&self) -> SpawnLine {
        SpawnLine {
            command: self.command(),
            args: Vec::new(),
            cwd: "/".to_owned(),
            env: Vec::new(),
            stdin: None,
            sandbox: None,
        }
    }
}

impl Drop for FakeCli {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

/// Échappe une chaîne pour l'insérer en argument shell entre quotes simples.
fn shell_single_quote(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for c in s.chars() {
        if c == '\'' {
            out.push_str("'\\''");
        } else {
            out.push(c);
        }
    }
    out.push('\'');
    out
}

/// Attend que `path` soit réellement exécutable (probe `execve` qui ne retourne
/// plus `ETXTBSY`). Sur Linux, exécuter un fichier encore ouvert en écriture par un
/// autre thread échoue avec « Text file busy » : on boucle un court instant jusqu'à
/// ce que la condition se lève, garantissant qu'un `spawn` ultérieur ne *race* pas.
#[cfg(unix)]
pub(crate) fn wait_until_executable(path: &std::path::Path) {
    use std::process::{Command, Stdio};
    use std::time::{Duration, Instant};

    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        // Probe la plus légère possible : on tente le spawn ; ETXTBSY ⇒ on réessaie.
        match Command::new(path)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
        {
            Ok(mut child) => {
                let _ = child.wait();
                return;
            }
            Err(e) if e.raw_os_error() == Some(26) && Instant::now() < deadline => {
                std::thread::sleep(Duration::from_millis(2));
            }
            // Toute autre erreur (ou dépassement de délai) : on rend la main, le test
            // appelant remontera l'échec réel s'il subsiste.
            Err(_) => return,
        }
    }
}

#[cfg(not(unix))]
pub(crate) fn wait_until_executable(_path: &std::path::Path) {}

#[cfg(unix)]
fn set_executable(path: &std::path::Path) {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(path)
        .expect("metadata fake CLI")
        .permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms).expect("chmod fake CLI");
}

#[cfg(not(unix))]
fn set_executable(_path: &std::path::Path) {
    // Sur les plateformes non-Unix le harnais s'appuiera sur un fake CLI adapté
    // (ex. `.cmd`) ; non requis pour la CI Linux actuelle.
}

// ---------------------------------------------------------------------------
// Harnais de conformité de port (Liskov) — réutilisable Claude ET Codex
// ---------------------------------------------------------------------------

#[cfg(test)]
pub(crate) mod harness {
    use std::sync::Arc;

    use domain::ports::{AgentSession, ReplyEvent};

    /// Asserte le **contrat [`AgentSession`]** sur une session déjà construite
    /// derrière un fake CLI dont le script produit ≥0 deltas/activités puis **un**
    /// `Final` portant `expected_final`, et dont l'init assigne `expected_conv_id`.
    ///
    /// Vérifie (substituabilité Liskov, §17.2) :
    /// 1. `send` émet une séquence de deltas/activités **puis exactement un** `Final` ;
    /// 2. après le `Final` le flux est **clos** (plus aucun événement) ;
    /// 3. le `Final` porte bien `expected_final` ;
    /// 4. `conversation_id()` devient `Some(expected_conv_id)` après le tour assignant ;
    /// 5. `shutdown()` réussit et est **idempotent** (deux appels OK).
    pub async fn assert_agent_session_contract(
        session: Arc<dyn AgentSession>,
        expected_conv_id: &str,
        expected_final: &str,
    ) {
        // Avant tout tour : aucun id de conversation assigné.
        assert_eq!(
            session.conversation_id(),
            None,
            "conversation_id doit être None avant le premier tour"
        );

        let stream = session.send("salut").await.expect("send doit réussir");
        let events: Vec<ReplyEvent> = stream.collect();

        // (1)+(2) : exactement un Final, en dernière position.
        let final_count = events
            .iter()
            .filter(|e| matches!(e, ReplyEvent::Final { .. }))
            .count();
        assert_eq!(final_count, 1, "le flux doit porter EXACTEMENT un Final");
        match events.last() {
            Some(ReplyEvent::Final { content }) => {
                // (3)
                assert_eq!(content, expected_final, "contenu Final inattendu");
            }
            other => panic!("le dernier événement doit être Final, vu: {other:?}"),
        }
        // Les événements avant le Final ne sont que des deltas / activités / heartbeats
        // / rate-limited (tous **non terminaux** ; le heartbeat est une preuve de
        // vivacité, lot 1 ; le RateLimited s'intercale comme un heartbeat, §21-T4).
        for e in &events[..events.len() - 1] {
            assert!(
                matches!(
                    e,
                    ReplyEvent::TextDelta { .. }
                        | ReplyEvent::ToolActivity { .. }
                        | ReplyEvent::Announcement { .. }
                        | ReplyEvent::Heartbeat
                        | ReplyEvent::RateLimited { .. }
                ),
                "avant le Final, seuls deltas/activités/heartbeats/rate-limited sont permis, vu: {e:?}"
            );
        }

        // (4) : id de conversation capté après le tour assignant.
        assert_eq!(
            session.conversation_id().as_deref(),
            Some(expected_conv_id),
            "conversation_id doit être assigné après le premier tour"
        );

        // (5) : shutdown réussit et est idempotent.
        session.shutdown().await.expect("shutdown doit réussir");
        session
            .shutdown()
            .await
            .expect("shutdown doit être idempotent");
    }
}
