//! L5 tests for [`CliAgentRuntime`].
//!
//! Covers:
//! - `prepare_invocation` (the **pure** core): for every [`ContextInjection`]
//!   strategy, the produced [`SpawnSpec`] (command, args order, resolved cwd,
//!   `context_plan`) is asserted.
//! - `detection_spec` (pure): custom `detect` tokenisation vs `--version`
//!   fallback.
//! - `detect` driven by a **mocked** [`ProcessSpawner`]: exit 0 ⇒ `true`,
//!   non-zero ⇒ `false`, spawner error ⇒ propagated as `RuntimeError`.

use std::sync::Arc;

use async_trait::async_trait;

use domain::ids::ProfileId;
use domain::ports::{
    AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
    ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
};
use domain::profile::{
    AgentProfile, ContextInjection, HttpChatConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::ProjectPath;
use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile {
    AgentProfile::new(
        ProfileId::from_uuid(uuid::Uuid::from_u128(1)),
        "Test",
        "mycli",
        vec!["--static".to_owned(), "arg".to_owned()],
        injection,
        Some("mycli probe --json".to_owned()),
        cwd_template,
        None,
    )
    .unwrap()
}

fn ctx() -> PreparedContext {
    PreparedContext {
        content: MarkdownDoc::new("# hi"),
        relative_path: ".ideai/agent.md".to_owned(),
        project_root: "/repo".to_owned(),
    }
}

/// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec.
struct FixedSpawner(Result<Output, ProcessError>);

#[async_trait]
impl ProcessSpawner for FixedSpawner {
    async fn run(&self, _spec: SpawnSpec) -> Result<Output, ProcessError> {
        self.0.clone()
    }
}

fn runtime_with(outcome: Result<Output, ProcessError>) -> CliAgentRuntime {
    CliAgentRuntime::new(Arc::new(FixedSpawner(outcome)))
}

/// A spawner that just records the spec it was handed (for detect-spec assertions).
struct RecordingSpawner(std::sync::Mutex<Option<SpawnSpec>>);

#[async_trait]
impl ProcessSpawner for RecordingSpawner {
    async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
        *self.0.lock().unwrap() = Some(spec);
        Ok(Output {
            status: ExitStatus { code: Some(0) },
            stdout: Vec::new(),
            stderr: Vec::new(),
        })
    }
}

fn pure_runtime() -> CliAgentRuntime {
    runtime_with(Ok(Output {
        status: ExitStatus { code: Some(0) },
        stdout: Vec::new(),
        stderr: Vec::new(),
    }))
}

// ---------------------------------------------------------------------------
// prepare_invocation — ConventionFile
// ---------------------------------------------------------------------------

#[test]
fn prepare_convention_file_keeps_args_and_plans_file() {
    let rt = pure_runtime();
    let p = profile(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        "{projectRoot}",
    );
    let root = ProjectPath::new("/home/me/proj").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();

    assert_eq!(spec.command, "mycli");
    assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
    assert_eq!(spec.cwd.as_str(), "/home/me/proj");
    assert_eq!(
        spec.context_plan,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned()
        })
    );
}

// ---------------------------------------------------------------------------
// prepare_invocation — Flag with {path}
// ---------------------------------------------------------------------------

#[test]
fn prepare_flag_with_path_substitutes_and_splits() {
    let rt = pure_runtime();
    let p = profile(
        ContextInjection::flag("--context-file {path}").unwrap(),
        "{projectRoot}",
    );
    let root = ProjectPath::new("/p").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();

    // static args first, then the substituted+split flag args.
    assert_eq!(
        spec.args,
        vec!["--static", "arg", "--context-file", ".ideai/agent.md"]
    );
    assert_eq!(
        spec.context_plan,
        Some(ContextInjectionPlan::Args {
            args: vec!["--context-file".to_owned(), ".ideai/agent.md".to_owned()]
        })
    );
}

// ---------------------------------------------------------------------------
// prepare_invocation — Flag without {path} (switch + path)
// ---------------------------------------------------------------------------

#[test]
fn prepare_flag_without_path_is_switch_then_path() {
    let rt = pure_runtime();
    let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
    let root = ProjectPath::new("/p").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
    assert_eq!(
        spec.context_plan,
        Some(ContextInjectionPlan::Args {
            args: vec!["-f".to_owned(), ".ideai/agent.md".to_owned()]
        })
    );
}

// ---------------------------------------------------------------------------
// prepare_invocation — Stdin
// ---------------------------------------------------------------------------

#[test]
fn prepare_stdin_keeps_args_and_plans_stdin() {
    let rt = pure_runtime();
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    let root = ProjectPath::new("/p").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();

    assert_eq!(
        spec.args,
        vec!["--static", "arg"],
        "args unchanged for stdin"
    );
    assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
}

// ---------------------------------------------------------------------------
// prepare_invocation — Env
// ---------------------------------------------------------------------------

#[test]
fn prepare_env_keeps_args_and_plans_env() {
    let rt = pure_runtime();
    let p = profile(
        ContextInjection::env("AGENT_CONTEXT").unwrap(),
        "{projectRoot}",
    );
    let root = ProjectPath::new("/p").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
    assert_eq!(
        spec.context_plan,
        Some(ContextInjectionPlan::Env {
            var: "AGENT_CONTEXT".to_owned()
        })
    );
}

// ---------------------------------------------------------------------------
// prepare_invocation — cwd template substitution
// ---------------------------------------------------------------------------

#[test]
fn prepare_substitutes_project_root_in_cwd_template() {
    let rt = pure_runtime();
    let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir");
    let root = ProjectPath::new("/home/me/proj").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
        .unwrap();
    assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
}

#[test]
fn prepare_empty_cwd_template_defaults_to_base() {
    let rt = pure_runtime();
    let p = profile(ContextInjection::stdin(), "");
    let base = ProjectPath::new("/home/me/proj").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &base, &SessionPlan::None)
        .unwrap();
    assert_eq!(spec.cwd.as_str(), "/home/me/proj");
}

#[test]
fn prepare_substitutes_agent_run_dir_in_cwd_template() {
    // The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
    // base cwd the launcher passes — the agent's isolated run directory.
    let rt = pure_runtime();
    let p = profile(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        "{agentRunDir}",
    );
    let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();

    let spec = rt
        .prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None)
        .unwrap();
    assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
}

// ---------------------------------------------------------------------------
// detection_spec (pure)
// ---------------------------------------------------------------------------

#[test]
fn detection_spec_uses_custom_detect_tokenised() {
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    let spec = CliAgentRuntime::detection_spec(&p).unwrap();
    assert_eq!(spec.command, "mycli");
    assert_eq!(spec.args, vec!["probe", "--json"]);
    assert!(spec.context_plan.is_none());
    assert!(spec.env.is_empty());
}

#[test]
fn detection_spec_falls_back_to_command_version() {
    let p = AgentProfile::new(
        ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
        "NoDetect",
        "somecli",
        Vec::new(),
        ContextInjection::stdin(),
        None,
        "{projectRoot}",
        None,
    )
    .unwrap();

    let spec = CliAgentRuntime::detection_spec(&p).unwrap();
    assert_eq!(spec.command, "somecli");
    assert_eq!(spec.args, vec!["--version"]);
}

// ---------------------------------------------------------------------------
// detect (mocked spawner)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn detect_true_on_exit_zero() {
    let rt = runtime_with(Ok(Output {
        status: ExitStatus { code: Some(0) },
        stdout: Vec::new(),
        stderr: Vec::new(),
    }));
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    assert!(rt.detect(&p).await.unwrap());
}

#[tokio::test]
async fn detect_false_on_nonzero_exit() {
    let rt = runtime_with(Ok(Output {
        status: ExitStatus { code: Some(127) },
        stdout: Vec::new(),
        stderr: Vec::new(),
    }));
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    assert!(!rt.detect(&p).await.unwrap());
}

#[tokio::test]
async fn detect_false_on_signal_terminated() {
    let rt = runtime_with(Ok(Output {
        status: ExitStatus { code: None },
        stdout: Vec::new(),
        stderr: Vec::new(),
    }));
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    assert!(!rt.detect(&p).await.unwrap());
}

#[tokio::test]
async fn detect_propagates_spawner_error() {
    let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
    let p = profile(ContextInjection::stdin(), "{projectRoot}");
    let err = rt.detect(&p).await.expect_err("spawner error surfaces");
    assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
}

#[tokio::test]
async fn detect_runs_the_detection_spec_command() {
    let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
    let rt = CliAgentRuntime::new(recorder.clone());
    let p = profile(ContextInjection::stdin(), "{projectRoot}");

    rt.detect(&p).await.unwrap();

    let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
    assert_eq!(spec.command, "mycli");
    assert_eq!(spec.args, vec!["probe", "--json"]);
}

#[tokio::test]
async fn detect_openai_compatible_uses_http_endpoint_not_cli_spawner() {
    let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
    let rt = CliAgentRuntime::new(recorder.clone());
    let p = profile(ContextInjection::stdin(), "{projectRoot}")
        .with_structured_adapter(StructuredAdapter::OpenAiCompatible)
        .with_chat_http(
            HttpChatConfig::new(
                "http://127.0.0.1:1/v1",
                "model",
                None,
                Some(1000),
                Some(1000),
                None,
            )
            .unwrap(),
        );

    assert!(!rt.detect(&p).await.unwrap());
    assert!(
        recorder.0.lock().unwrap().is_none(),
        "OpenAI-compatible detection must not spawn the profile command"
    );
}

// ---------------------------------------------------------------------------
// prepare_invocation — session args (T3 truth table)
// ---------------------------------------------------------------------------

/// Like [`profile`] but carries a [`SessionStrategy`]. `Stdin` injection keeps
/// the context out of the args so session args are the *only* trailing tokens.
fn profile_with_session(session: Option<SessionStrategy>) -> AgentProfile {
    AgentProfile::new(
        ProfileId::from_uuid(uuid::Uuid::from_u128(7)),
        "Test",
        "mycli",
        vec!["--static".to_owned(), "arg".to_owned()],
        ContextInjection::stdin(),
        Some("mycli probe --json".to_owned()),
        "{agentRunDir}",
        session,
    )
    .unwrap()
}

fn root() -> ProjectPath {
    ProjectPath::new("/p").unwrap()
}

// Row 1: profile.session == None ⇒ no args added, whatever the plan.
#[test]
fn session_none_profile_adds_nothing_for_any_plan() {
    let rt = pure_runtime();
    let p = profile_with_session(None);

    for plan in [
        SessionPlan::None,
        SessionPlan::Assign {
            conversation_id: "id-1".to_owned(),
        },
        SessionPlan::Resume {
            conversation_id: "id-1".to_owned(),
        },
    ] {
        let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
        assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
    }
}

// Row 2: Some{assign_flag: Some(f)} + Assign{id} ⇒ [f, id].
#[test]
fn session_assign_with_flag_emits_flag_and_id() {
    let rt = pure_runtime();
    let p = profile_with_session(Some(
        SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
    ));

    let spec = rt
        .prepare_invocation(
            &p,
            &ctx(),
            &root(),
            &SessionPlan::Assign {
                conversation_id: "abc".to_owned(),
            },
        )
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg", "--session-id", "abc"]);
}

// Row 3: Some{resume_flag: r, assign_flag: Some} + Resume{id} ⇒ [r, id].
#[test]
fn session_resume_with_flag_emits_resume_and_id() {
    let rt = pure_runtime();
    let p = profile_with_session(Some(
        SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
    ));

    let spec = rt
        .prepare_invocation(
            &p,
            &ctx(),
            &root(),
            &SessionPlan::Resume {
                conversation_id: "abc".to_owned(),
            },
        )
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg", "--resume", "abc"]);
}

// Row 4: Some{assign_flag: None, resume_flag: r} + Resume{id} ⇒ [r] (degraded).
#[test]
fn session_resume_without_assign_flag_emits_resume_only() {
    let rt = pure_runtime();
    let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));

    let spec = rt
        .prepare_invocation(
            &p,
            &ctx(),
            &root(),
            &SessionPlan::Resume {
                conversation_id: "abc".to_owned(),
            },
        )
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg", "--continue"]);
}

// Row 5: Some{..} + SessionPlan::None ⇒ nothing added.
#[test]
fn session_plan_none_with_strategy_adds_nothing() {
    let rt = pure_runtime();
    let p = profile_with_session(Some(
        SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
    ));

    let spec = rt
        .prepare_invocation(&p, &ctx(), &root(), &SessionPlan::None)
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg"]);
}

// Row 6: Some{assign_flag: None} + Assign{id} ⇒ nothing (no assign possible).
#[test]
fn session_assign_without_flag_adds_nothing() {
    let rt = pure_runtime();
    let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));

    let spec = rt
        .prepare_invocation(
            &p,
            &ctx(),
            &root(),
            &SessionPlan::Assign {
                conversation_id: "abc".to_owned(),
            },
        )
        .unwrap();

    assert_eq!(spec.args, vec!["--static", "arg"]);
}

// Non-regression: a profile WITHOUT session + SessionPlan::None yields the exact
// same args as before T3 (the existing strategy tests already assert these; here
// we pin it against an Assign/Resume plan too — still nothing added).
#[test]
fn no_session_profile_is_unaffected_by_any_plan() {
    let rt = pure_runtime();
    let p = profile(ContextInjection::stdin(), "{agentRunDir}");

    for plan in [
        SessionPlan::None,
        SessionPlan::Assign {
            conversation_id: "x".to_owned(),
        },
        SessionPlan::Resume {
            conversation_id: "x".to_owned(),
        },
    ] {
        let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
        assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
    }
}

// Ordering: session args land *after* the context-injection (Flag) args.
#[test]
fn session_args_come_after_context_injection_args() {
    let rt = pure_runtime();
    let p = AgentProfile::new(
        ProfileId::from_uuid(uuid::Uuid::from_u128(8)),
        "Test",
        "mycli",
        vec!["--static".to_owned()],
        ContextInjection::flag("--context-file {path}").unwrap(),
        Some("mycli probe".to_owned()),
        "{agentRunDir}",
        Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()),
    )
    .unwrap();

    let spec = rt
        .prepare_invocation(
            &p,
            &ctx(),
            &root(),
            &SessionPlan::Assign {
                conversation_id: "abc".to_owned(),
            },
        )
        .unwrap();

    // static arg, then context-injection args, then session args — in that order.
    assert_eq!(
        spec.args,
        vec![
            "--static",
            "--context-file",
            ".ideai/agent.md",
            "--session-id",
            "abc"
        ]
    );
}
