//! L5 tests for the profile/first-run use cases and the reference catalogue.
//!
//! Ports are faked in-memory so the use cases run without any I/O:
//! - [`FakeProfileStore`] — an in-memory [`ProfileStore`] tracking a `configured`
//!   flag (mirrors `profiles.json` existence),
//! - [`StubRuntime`] — an [`AgentRuntime`] whose `detect` is driven by a map from
//!   command → result (including an error case to prove graceful degradation).

use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use async_trait::async_trait;

use domain::ids::ProfileId;
use domain::ports::{
    AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SecretRef, SecretStore,
    SecretStoreError, SessionPlan, SpawnSpec, StoreError,
};
use domain::profile::{
    AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
};
use domain::project::ProjectPath;

use application::{
    claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles,
    AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
    CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput,
    DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
    ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
    SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};

// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------

#[derive(Default)]
struct FakeStoreInner {
    profiles: Vec<AgentProfile>,
    configured: bool,
}

#[derive(Default, Clone)]
struct FakeProfileStore(Arc<Mutex<FakeStoreInner>>);

#[async_trait]
impl ProfileStore for FakeProfileStore {
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
        Ok(self.0.lock().unwrap().profiles.clone())
    }

    async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
        let mut inner = self.0.lock().unwrap();
        inner.configured = true;
        if let Some(slot) = inner.profiles.iter_mut().find(|p| p.id == profile.id) {
            *slot = profile.clone();
        } else {
            inner.profiles.push(profile.clone());
        }
        Ok(())
    }

    async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
        let mut inner = self.0.lock().unwrap();
        let before = inner.profiles.len();
        inner.profiles.retain(|p| p.id != id);
        if inner.profiles.len() == before {
            return Err(StoreError::NotFound);
        }
        Ok(())
    }

    async fn is_configured(&self) -> Result<bool, StoreError> {
        Ok(self.0.lock().unwrap().configured)
    }

    async fn mark_configured(&self) -> Result<(), StoreError> {
        self.0.lock().unwrap().configured = true;
        Ok(())
    }
}

#[derive(Default, Clone)]
struct FakeSecretStore(Arc<Mutex<HashMap<String, String>>>);

#[async_trait]
impl SecretStore for FakeSecretStore {
    async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
        self.0
            .lock()
            .unwrap()
            .insert(key.as_str().to_owned(), value.to_owned());
        Ok(())
    }

    async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
        Ok(self.0.lock().unwrap().get(key.as_str()).cloned())
    }

    async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
        self.0.lock().unwrap().remove(key.as_str());
        Ok(())
    }
}

/// Detection outcomes keyed by command. Missing keys ⇒ `false`.
#[derive(Clone)]
enum DetectResult {
    Available,
    Missing,
    Error,
}

struct StubRuntime {
    by_command: HashMap<String, DetectResult>,
}

struct YieldingRuntime;

struct SlowRuntime {
    delay: Duration,
    started: Arc<AtomicUsize>,
    active: Arc<AtomicUsize>,
    max_active: Arc<AtomicUsize>,
}

#[async_trait]
impl AgentRuntime for StubRuntime {
    async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
        match self.by_command.get(&profile.command) {
            Some(DetectResult::Available) => Ok(true),
            Some(DetectResult::Missing) | None => Ok(false),
            Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())),
        }
    }

    fn prepare_invocation(
        &self,
        _profile: &AgentProfile,
        _ctx: &PreparedContext,
        _cwd: &ProjectPath,
        _session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        unreachable!("not used in these tests")
    }
}

#[async_trait]
impl AgentRuntime for YieldingRuntime {
    async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
        tokio::task::yield_now().await;
        Ok(true)
    }

    fn prepare_invocation(
        &self,
        _profile: &AgentProfile,
        _ctx: &PreparedContext,
        _cwd: &ProjectPath,
        _session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        unreachable!("not used in these tests")
    }
}

#[async_trait]
impl AgentRuntime for SlowRuntime {
    async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
        self.started.fetch_add(1, Ordering::SeqCst);
        let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
        self.max_active.fetch_max(active, Ordering::SeqCst);
        tokio::time::sleep(self.delay).await;
        self.active.fetch_sub(1, Ordering::SeqCst);
        Ok(false)
    }

    fn prepare_invocation(
        &self,
        _profile: &AgentProfile,
        _ctx: &PreparedContext,
        _cwd: &ProjectPath,
        _session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        unreachable!("not used in these tests")
    }
}

struct SeqIds(Mutex<Vec<uuid::Uuid>>);

impl SeqIds {
    fn new(ids: Vec<uuid::Uuid>) -> Self {
        Self(Mutex::new(ids))
    }
}

impl IdGenerator for SeqIds {
    fn new_uuid(&self) -> uuid::Uuid {
        self.0.lock().unwrap().remove(0)
    }
}

fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
    AgentProfile::new(
        ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
        name,
        command,
        Vec::new(),
        ContextInjection::stdin(),
        Some(format!("{command} --version")),
        "{projectRoot}",
        None,
    )
    .unwrap()
}

// ---------------------------------------------------------------------------
// DetectProfiles
// ---------------------------------------------------------------------------

#[tokio::test]
async fn detect_maps_candidates_to_availability_in_order() {
    let mut map = HashMap::new();
    map.insert("claude".to_owned(), DetectResult::Available);
    map.insert("codex".to_owned(), DetectResult::Missing);
    let runtime: Arc<dyn AgentRuntime> = Arc::new(StubRuntime { by_command: map });

    let detect = DetectProfiles::new(runtime);
    let out = detect
        .execute(DetectProfilesInput {
            candidates: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")],
        })
        .await
        .unwrap();

    assert_eq!(out.results.len(), 2);
    assert_eq!(out.results[0].profile.command, "claude");
    assert!(out.results[0].available);
    assert_eq!(out.results[1].profile.command, "codex");
    assert!(!out.results[1].available);
}

#[tokio::test]
async fn detect_error_degrades_to_unavailable_not_hard_failure() {
    let mut map = HashMap::new();
    map.insert("aider".to_owned(), DetectResult::Error);
    let runtime: Arc<dyn AgentRuntime> = Arc::new(StubRuntime { by_command: map });

    let detect = DetectProfiles::new(runtime);
    let out = detect
        .execute(DetectProfilesInput {
            candidates: vec![profile(1, "Aider", "aider")],
        })
        .await
        .expect("detection error must not fail the use case");

    assert!(
        !out.results[0].available,
        "errored detection ⇒ available:false"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() {
    let detect = DetectProfiles::new(Arc::new(YieldingRuntime));
    let out = detect
        .execute(DetectProfilesInput {
            candidates: vec![profile(1, "Claude", "claude")],
        })
        .await
        .expect("async detection must complete inside a multi-thread Tokio runtime");

    assert_eq!(out.results.len(), 1);
    assert!(out.results[0].available);
}

#[tokio::test(flavor = "multi_thread")]
async fn detect_profiles_runs_candidate_probes_concurrently_and_keeps_order() {
    let started = Arc::new(AtomicUsize::new(0));
    let active = Arc::new(AtomicUsize::new(0));
    let max_active = Arc::new(AtomicUsize::new(0));
    let delay = Duration::from_millis(100);
    let runtime: Arc<dyn AgentRuntime> = Arc::new(SlowRuntime {
        delay,
        started: Arc::clone(&started),
        active,
        max_active: Arc::clone(&max_active),
    });
    let candidates = vec![
        profile(1, "One", "one"),
        profile(2, "Two", "two"),
        profile(3, "Three", "three"),
        profile(4, "Four", "four"),
        profile(5, "Five", "five"),
    ];
    let detect = DetectProfiles::new(runtime);

    let started_at = Instant::now();
    let out = detect
        .execute(DetectProfilesInput { candidates })
        .await
        .unwrap();
    let elapsed = started_at.elapsed();

    assert_eq!(started.load(Ordering::SeqCst), 5, "all candidates probed");
    assert_eq!(
        max_active.load(Ordering::SeqCst),
        5,
        "all probes should overlap instead of running sequentially"
    );
    assert!(
        elapsed < Duration::from_millis(300),
        "parallel probes should take roughly one probe duration, got {elapsed:?}"
    );
    assert_eq!(
        out.results
            .iter()
            .map(|entry| entry.profile.command.as_str())
            .collect::<Vec<_>>(),
        vec!["one", "two", "three", "four", "five"],
        "result order stays aligned with candidate order"
    );
    assert!(out.results.iter().all(|entry| !entry.available));
}

// ---------------------------------------------------------------------------
// ConfigureProfiles
// ---------------------------------------------------------------------------

#[tokio::test]
async fn configure_persists_chosen_profiles_and_closes_first_run() {
    let store = FakeProfileStore::default();
    let configure = ConfigureProfiles::new(Arc::new(store.clone()));

    let out = configure
        .execute(ConfigureProfilesInput {
            profiles: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")],
        })
        .await
        .unwrap();

    assert_eq!(out.profiles.len(), 2);
    assert!(store.is_configured().await.unwrap());
    assert_eq!(store.list().await.unwrap().len(), 2);
}

#[tokio::test]
async fn configure_empty_list_still_marks_configured() {
    let store = FakeProfileStore::default();
    let configure = ConfigureProfiles::new(Arc::new(store.clone()));

    configure
        .execute(ConfigureProfilesInput { profiles: vec![] })
        .await
        .unwrap();

    assert!(
        store.is_configured().await.unwrap(),
        "empty configure closes the first run"
    );
    assert!(store.list().await.unwrap().is_empty());
}

// ---------------------------------------------------------------------------
// FirstRunState
// ---------------------------------------------------------------------------

#[tokio::test]
async fn first_run_true_when_not_configured_with_reference_catalogue() {
    let store = FakeProfileStore::default();
    let uc = FirstRunState::new(Arc::new(store));

    let out = uc.execute().await.unwrap();
    assert!(out.is_first_run);
    // §17.3/D7: the wizard is seeded only with the *selectable* (structured-
    // drivable) profiles — Claude + Codex + OpenCode — not the full
    // catalogue.
    assert_eq!(
        out.reference_profiles.len(),
        3,
        "selectable catalogue seeded"
    );
    let commands: Vec<&str> = out
        .reference_profiles
        .iter()
        .map(|p| p.command.as_str())
        .collect();
    assert_eq!(
        commands,
        vec!["claude", "codex", "opencode"],
        "only structured profiles offered"
    );
    // Every seeded profile is selectable (the gate the menu relies on).
    assert!(
        out.reference_profiles
            .iter()
            .all(AgentProfile::is_selectable),
        "seeded profiles must all be selectable"
    );
}

#[tokio::test]
async fn first_run_false_after_configuration() {
    let store = FakeProfileStore::default();
    store.mark_configured().await.unwrap();
    let uc = FirstRunState::new(Arc::new(store));

    let out = uc.execute().await.unwrap();
    assert!(!out.is_first_run);
}

// ---------------------------------------------------------------------------
// ListProfiles / SaveProfile / DeleteProfile
// ---------------------------------------------------------------------------

#[tokio::test]
async fn save_then_list_then_delete() {
    let store = FakeProfileStore::default();
    let save = SaveProfile::new(Arc::new(store.clone()));
    let list = ListProfiles::new(Arc::new(store.clone()));
    let delete = DeleteProfile::new(
        Arc::new(store.clone()),
        Arc::new(FakeSecretStore::default()),
    );

    let p = profile(1, "Claude", "claude");
    let saved = save
        .execute(SaveProfileInput { profile: p.clone() })
        .await
        .unwrap();
    assert_eq!(saved.profile, p);

    assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]);

    delete
        .execute(DeleteProfileInput { id: p.id })
        .await
        .unwrap();
    assert!(list.execute().await.unwrap().profiles.is_empty());
}

#[tokio::test]
async fn delete_unknown_is_not_found_error() {
    let store = FakeProfileStore::default();
    let delete = DeleteProfile::new(Arc::new(store), Arc::new(FakeSecretStore::default()));
    let err = delete
        .execute(DeleteProfileInput {
            id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)),
        })
        .await
        .expect_err("deleting unknown id errors");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

#[tokio::test]
async fn save_opencode_provider_profile_seals_the_literal_key_behind_a_secret_ref() {
    let store = FakeProfileStore::default();
    let secrets = FakeSecretStore::default();
    let save = SaveOpenCodeProviderProfile::new(
        Arc::new(store.clone()),
        Arc::new(secrets.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9201)])),
    );

    let out = save
        .execute(SaveOpenCodeProviderProfileInput {
            profile: profile(92, "OpenCode Anthropic", "opencode"),
            provider_id: "anthropic".to_owned(),
            model: "claude-sonnet-5".to_owned(),
            api_key: "sk-live-literal-secret".to_owned(),
            custom: None,
        })
        .await
        .unwrap();

    let provider = out
        .profile
        .opencode_provider
        .as_ref()
        .expect("opencode_provider is set");
    assert_eq!(provider.provider_id, "anthropic");
    assert_eq!(provider.model, "claude-sonnet-5");

    // The ref is opaque, not the literal key.
    assert_ne!(provider.api_key_ref.as_str(), "sk-live-literal-secret");

    // The literal key never leaks onto the returned profile — only the ref does.
    let profile_json = serde_json::to_string(&out.profile).unwrap();
    assert!(!profile_json.contains("sk-live-literal-secret"));

    // The literal key is retrievable ONLY via the SecretStore, through the ref.
    let resolved = secrets.get(&provider.api_key_ref).await.unwrap();
    assert_eq!(resolved, Some("sk-live-literal-secret".to_owned()));
}

#[tokio::test]
async fn save_opencode_provider_profile_drops_stale_local_backend() {
    // Regression (ticket #97): the first-run wizard could hand in a profile that
    // already carries an `opencode` (llamacpp) backend. Saving a cloud provider
    // must clear it, otherwise the persisted profile keeps both backends and
    // llamacpp wins at runtime.
    let store = FakeProfileStore::default();
    let secrets = FakeSecretStore::default();
    let save = SaveOpenCodeProviderProfile::new(
        Arc::new(store.clone()),
        Arc::new(secrets.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9710)])),
    );

    let base = profile(94, "OpenCode Cloud", "opencode")
        .with_structured_adapter(StructuredAdapter::OpenCode)
        .with_opencode(
            OpenCodeConfig::new(
                "http://localhost:8080/v1",
                None,
                "qwen3-coder-30b",
                None,
                None,
            )
            .unwrap(),
        );
    assert!(
        base.opencode.is_some(),
        "precondition: input profile carries the stale local backend"
    );

    let out = save
        .execute(SaveOpenCodeProviderProfileInput {
            profile: base,
            provider_id: "anthropic".to_owned(),
            model: "claude-sonnet-5".to_owned(),
            api_key: "sk-cloud".to_owned(),
            custom: None,
        })
        .await
        .unwrap();

    assert!(
        out.profile.opencode.is_none(),
        "stale local backend dropped"
    );
    assert_eq!(
        out.profile.opencode_provider.as_ref().unwrap().provider_id,
        "anthropic"
    );
    assert!(
        out.profile.opencode_backend_is_consistent(),
        "persisted profile honours the mutual-exclusion invariant"
    );

    // The cleared state is what reached the store.
    let persisted = store.list().await.unwrap();
    let saved = persisted
        .iter()
        .find(|p| p.id == out.profile.id)
        .expect("profile persisted");
    assert!(saved.opencode.is_none());
    assert!(saved.opencode_provider.is_some());
}

#[tokio::test]
async fn delete_profile_with_opencode_provider_purges_its_secret() {
    let store = FakeProfileStore::default();
    let secrets = FakeSecretStore::default();
    let save = SaveOpenCodeProviderProfile::new(
        Arc::new(store.clone()),
        Arc::new(secrets.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9202)])),
    );
    let saved = save
        .execute(SaveOpenCodeProviderProfileInput {
            profile: profile(93, "OpenCode OpenRouter", "opencode"),
            provider_id: "openrouter".to_owned(),
            model: "anthropic/claude-sonnet-5".to_owned(),
            api_key: "sk-live-to-be-purged".to_owned(),
            custom: None,
        })
        .await
        .unwrap();
    let secret_ref = saved
        .profile
        .opencode_provider
        .as_ref()
        .unwrap()
        .api_key_ref
        .clone();
    assert_eq!(
        secrets.get(&secret_ref).await.unwrap(),
        Some("sk-live-to-be-purged".to_owned())
    );

    let delete = DeleteProfile::new(Arc::new(store.clone()), Arc::new(secrets.clone()));
    delete
        .execute(DeleteProfileInput {
            id: saved.profile.id,
        })
        .await
        .unwrap();

    assert_eq!(secrets.get(&secret_ref).await.unwrap(), None);
}

#[tokio::test]
async fn save_profile_persists_codex_claude_model_without_provider_or_secret_surface() {
    let store = FakeProfileStore::default();
    let save = SaveProfile::new(Arc::new(store.clone()));
    let profile = profile(991, "Codex GPT-5", "codex").with_model("gpt-5-codex");

    let out = save
        .execute(SaveProfileInput {
            profile: profile.clone(),
        })
        .await
        .unwrap();

    assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
    let json = serde_json::to_string(&out.profile).unwrap();
    assert!(!json.contains("codexProvider"), "got: {json}");
    assert!(!json.contains("claudeProvider"), "got: {json}");
    assert!(!json.contains("apiKey"), "got: {json}");
    assert!(!json.contains("providerId"), "got: {json}");
    assert_eq!(store.list().await.unwrap(), vec![profile]);
}

#[tokio::test]
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
    let store = FakeProfileStore::default();
    let clone = CloneOpenCodeProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
    );

    let out = clone
        .execute(CloneOpenCodeProfileFromSeedInput {
            name: Some("OpenCode llama.cpp 2".to_owned()),
            opencode: Some(
                OpenCodeConfig::new(
                    "http://localhost:8081/v1",
                    None,
                    "qwen3-coder-70b",
                    Some(false),
                    Some(true),
                )
                .unwrap(),
            ),
        })
        .await
        .unwrap();

    let seed_id = reference_profile_id("opencode-llamacpp");
    assert_ne!(out.profile.id, seed_id);
    assert_eq!(
        out.profile.id,
        ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
    );
    assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
    assert_eq!(
        out.profile.structured_adapter,
        Some(StructuredAdapter::OpenCode)
    );
    assert_eq!(
        out.profile
            .opencode
            .as_ref()
            .map(|config| config.base_url.as_str()),
        Some("http://localhost:8081/v1")
    );

    let profiles = store.0.lock().unwrap().profiles.clone();
    assert_eq!(profiles.len(), 1);
    assert_eq!(profiles[0].id, out.profile.id);
}

#[tokio::test]
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
    let store = FakeProfileStore::default();
    let save = SaveProfile::new(Arc::new(store.clone()));
    let mut seed = reference_profiles()
        .into_iter()
        .find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
        .expect("seed exists");
    seed.name = "Edited local OpenCode seed".to_owned();
    save.execute(SaveProfileInput {
        profile: seed.clone(),
    })
    .await
    .unwrap();

    let clone = CloneOpenCodeProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
    );
    let out = clone
        .execute(CloneOpenCodeProfileFromSeedInput {
            name: None,
            opencode: None,
        })
        .await
        .unwrap();

    assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
    assert_eq!(out.profile.opencode, seed.opencode);
    let profiles = store.0.lock().unwrap().profiles.clone();
    assert_eq!(
        profiles
            .iter()
            .filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
            .count(),
        1,
        "the canonical seed must be preserved, not recreated as a duplicate"
    );
    assert_eq!(
        profiles
            .iter()
            .filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
            .count(),
        2,
        "ProfileId is the identity: multiple OpenCode profiles can coexist"
    );
}

#[tokio::test]
async fn clone_opencode_profile_accepts_a_cloud_provider_seed() {
    // Regression (ticket #92 + #97): the canonical seed id is deterministic, so
    // a cloud-converted profile can end up occupying it — structured OpenCode
    // with `opencode_provider` set and `opencode` cleared. Cloning must succeed
    // (the seed is a valid OpenCode profile backed by a cloud provider) instead
    // of erroring "canonical OpenCode seed is not an OpenCode profile", and the
    // cloud backend must be reported on the clone.
    let store = FakeProfileStore::default();

    let cloud_seed = reference_profiles()
        .into_iter()
        .find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
        .expect("seed exists")
        .with_opencode_provider(
            OpenCodeProviderConfig::new(
                "anthropic".to_owned(),
                "claude-sonnet-5".to_owned(),
                SecretRef::new("ref-cloud-seed".to_owned()),
            )
            .unwrap(),
        );
    assert!(
        cloud_seed.opencode.is_none(),
        "precondition: cloud seed has no local backend"
    );
    assert!(
        cloud_seed.opencode_provider.is_some(),
        "precondition: cloud seed has a provider"
    );

    SaveProfile::new(Arc::new(store.clone()))
        .execute(SaveProfileInput {
            profile: cloud_seed.clone(),
        })
        .await
        .unwrap();

    let clone = CloneOpenCodeProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3701)])),
    );
    let out = clone
        .execute(CloneOpenCodeProfileFromSeedInput {
            name: Some("GLM5.2 copy".to_owned()),
            opencode: None,
        })
        .await
        .expect("cloning a cloud-backed seed must succeed");

    let seed_id = reference_profile_id("opencode-llamacpp");
    assert_ne!(out.profile.id, seed_id, "clone gets a fresh id");
    assert_eq!(
        out.profile.id,
        ProfileId::from_uuid(uuid::Uuid::from_u128(3701))
    );
    assert_eq!(out.profile.name, "GLM5.2 copy");
    assert_eq!(
        out.profile.structured_adapter,
        Some(StructuredAdapter::OpenCode),
        "structured adapter preserved"
    );
    assert!(
        out.profile.opencode.is_none(),
        "no local llamacpp backend synthesised on the clone"
    );
    assert_eq!(
        out.profile
            .opencode_provider
            .as_ref()
            .map(|config| config.provider_id.as_str()),
        Some("anthropic"),
        "cloud backend is reported on the clone"
    );
    assert!(
        out.profile.opencode_backend_is_consistent(),
        "clone honours the mutual-exclusion invariant"
    );

    // The original cloud seed is preserved untouched alongside the new clone.
    let profiles = store.0.lock().unwrap().profiles.clone();
    assert_eq!(profiles.len(), 2);
    assert!(
        profiles
            .iter()
            .any(|profile| profile.id == seed_id && profile.opencode_provider.is_some()),
        "canonical cloud seed preserved"
    );
}

#[tokio::test]
async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_not_opencode() {
    // Robustness: if the persisted slot occupying the canonical seed id is NOT a
    // valid OpenCode profile at all (unexpected store state), the use case falls
    // back to the in-memory reference catalogue seed instead of crashing.
    let store = FakeProfileStore::default();

    let squatter = AgentProfile::new(
        reference_profile_id("opencode-llamacpp"),
        "Squatter",
        "something-else",
        Vec::new(),
        ContextInjection::stdin(),
        None,
        "{projectRoot}",
        None,
    )
    .unwrap();
    assert_eq!(
        squatter.structured_adapter, None,
        "precondition: not an OpenCode profile"
    );

    SaveProfile::new(Arc::new(store.clone()))
        .execute(SaveProfileInput { profile: squatter })
        .await
        .unwrap();

    let clone = CloneOpenCodeProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3702)])),
    );
    let out = clone
        .execute(CloneOpenCodeProfileFromSeedInput {
            name: None,
            opencode: None,
        })
        .await
        .expect("catalogue fallback must not crash");

    // The clone is the local llamacpp catalogue seed (not the squatter).
    assert_eq!(
        out.profile.structured_adapter,
        Some(StructuredAdapter::OpenCode)
    );
    assert!(
        out.profile.opencode.is_some(),
        "fell back to the catalogue local llamacpp seed"
    );
    assert_eq!(
        out.profile
            .opencode
            .as_ref()
            .map(|config| config.model.as_str()),
        Some("qwen3-coder-30b"),
        "catalogue seed values are reported"
    );
    assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
}

#[tokio::test]
async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() {
    let store = FakeProfileStore::default();
    let clone = CloneProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])),
    );

    let out = clone
        .execute(CloneProfileFromSeedInput {
            seed_profile_id: reference_profile_id("codex"),
            name: Some("Codex GPT-5".to_owned()),
            model: Some("gpt-5-codex".to_owned()),
        })
        .await
        .unwrap();

    assert_eq!(
        out.profile.id,
        ProfileId::from_uuid(uuid::Uuid::from_u128(3801))
    );
    assert_eq!(out.profile.name, "Codex GPT-5");
    assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
    assert_eq!(
        out.profile.structured_adapter,
        Some(StructuredAdapter::Codex)
    );
    assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]);
}

#[tokio::test]
async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() {
    let store = FakeProfileStore::default();
    let persisted = reference_profiles()
        .into_iter()
        .find(|profile| profile.id == reference_profile_id("claude"))
        .expect("seed exists")
        .with_model("claude-opus-4-8");
    SaveProfile::new(Arc::new(store.clone()))
        .execute(SaveProfileInput {
            profile: persisted.clone(),
        })
        .await
        .unwrap();

    let clone = CloneProfileFromSeed::new(
        Arc::new(store.clone()),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])),
    );
    let out = clone
        .execute(CloneProfileFromSeedInput {
            seed_profile_id: persisted.id,
            name: None,
            model: None,
        })
        .await
        .unwrap();

    assert_eq!(out.profile.name, "Claude Code copy");
    assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8"));
    assert_eq!(
        out.profile.structured_adapter,
        Some(StructuredAdapter::Claude)
    );
    assert_ne!(out.profile.id, persisted.id);
    assert_eq!(store.0.lock().unwrap().profiles.len(), 2);
}

#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_model_override() {
    let store = FakeProfileStore::default();
    let clone = CloneProfileFromSeed::new(
        Arc::new(store),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])),
    );

    let err = clone
        .execute(CloneProfileFromSeedInput {
            seed_profile_id: reference_profile_id("claude"),
            name: Some("Claude blank".to_owned()),
            model: Some("  ".to_owned()),
        })
        .await
        .unwrap_err();

    assert!(matches!(err, AppError::Invalid(_)));
}

#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_name_override() {
    let store = FakeProfileStore::default();
    let clone = CloneProfileFromSeed::new(
        Arc::new(store),
        Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])),
    );

    let err = clone
        .execute(CloneProfileFromSeedInput {
            seed_profile_id: reference_profile_id("codex"),
            name: Some("  ".to_owned()),
            model: Some("gpt-5-codex".to_owned()),
        })
        .await
        .unwrap_err();

    assert!(matches!(err, AppError::Invalid(_)));
}

// ---------------------------------------------------------------------------
// ReferenceProfiles / catalogue
// ---------------------------------------------------------------------------

#[tokio::test]
async fn reference_profiles_use_case_returns_only_selectable() {
    // §17.3/D7: the selection use case exposes only structured-drivable profiles
    // (Claude + Codex + OpenCode). Gemini/Aider stay in the raw catalogue (see
    // `catalogue_*` tests below) but are not offered to selection/creation.
    let out = ReferenceProfiles::new().execute().await.unwrap();
    assert_eq!(out.profiles.len(), 3);
    let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
    assert_eq!(commands, vec!["claude", "codex", "opencode"]);
    assert!(out.profiles.iter().all(AgentProfile::is_selectable));
}

/// §17.3/D7 — non-regression: the *raw* catalogue still carries all reference profiles
/// (data intact). Only the selection-facing use case is filtered.
#[test]
fn raw_catalogue_still_has_all_profiles() {
    let commands: Vec<String> = reference_profiles()
        .iter()
        .map(|p| p.command.clone())
        .collect();
    assert_eq!(
        commands,
        vec!["claude", "codex", "opencode", "gemini", "aider"]
    );
}

#[test]
fn claude_and_codex_reference_profiles_roundtrip_with_byte_identity() {
    let profiles = reference_profiles();
    let by_command: HashMap<&str, &AgentProfile> =
        profiles.iter().map(|p| (p.command.as_str(), p)).collect();

    for command in ["claude", "codex"] {
        let before = serde_json::to_vec(by_command[command]).expect("serialize reference profile");
        let back: AgentProfile =
            serde_json::from_slice(&before).expect("deserialize reference profile");
        let after = serde_json::to_vec(&back).expect("serialize round-tripped profile");
        assert_eq!(
            after, before,
            "{command} profile JSON bytes must be strictly stable across a serde round-trip"
        );
        assert!(
            !String::from_utf8_lossy(&after).contains("\"chatHttp\""),
            "{command} is a historical non-HTTP profile and must not grow chatHttp"
        );
    }
}

/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the
/// structured-drivable profiles, false for the two PTY-only ones. This assertion
/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex
/// lost theirs — i.e. it actually constrains behaviour.
#[test]
fn is_selectable_is_true_only_for_structured_profiles() {
    let profiles = reference_profiles();
    let by_command: HashMap<&str, &AgentProfile> =
        profiles.iter().map(|p| (p.command.as_str(), p)).collect();
    assert!(
        by_command["claude"].is_selectable(),
        "Claude carries a structured adapter ⇒ selectable"
    );
    assert!(
        by_command["codex"].is_selectable(),
        "Codex carries a structured adapter ⇒ selectable"
    );
    assert!(
        by_command["opencode"].is_selectable(),
        "OpenCode carries a structured adapter ⇒ selectable"
    );
    assert!(
        !by_command["gemini"].is_selectable(),
        "Gemini has no adapter ⇒ not selectable"
    );
    assert!(
        !by_command["aider"].is_selectable(),
        "Aider has no adapter ⇒ not selectable"
    );
}

#[test]
fn catalogue_has_expected_commands_and_injection() {
    let profiles = reference_profiles();
    let by_command: HashMap<&str, &AgentProfile> =
        profiles.iter().map(|p| (p.command.as_str(), p)).collect();

    let claude = by_command["claude"];
    assert_eq!(
        claude.context_injection,
        ContextInjection::ConventionFile {
            target: "CLAUDE.md".to_owned()
        }
    );

    assert_eq!(
        by_command["codex"].context_injection,
        ContextInjection::ConventionFile {
            target: "AGENTS.md".to_owned()
        }
    );
    assert_eq!(
        by_command["codex"].submit_delay_ms,
        Some(CODEX_SUBMIT_DELAY_MS),
        "Codex TUI needs a conservative text→submit delay for delegated prompts"
    );
    assert_eq!(
        by_command["opencode"].context_injection,
        ContextInjection::ConventionFile {
            target: "AGENTS.md".to_owned()
        }
    );
    assert_eq!(
        by_command["gemini"].context_injection,
        ContextInjection::ConventionFile {
            target: "GEMINI.md".to_owned()
        }
    );
    assert_eq!(
        by_command["aider"].context_injection,
        ContextInjection::Flag {
            flag: "--message-file {path}".to_owned()
        }
    );
}

#[test]
fn catalogue_ids_are_stable_across_calls() {
    let first = reference_profiles();
    let second = reference_profiles();
    let ids_a: Vec<_> = first.iter().map(|p| p.id).collect();
    let ids_b: Vec<_> = second.iter().map(|p| p.id).collect();
    assert_eq!(ids_a, ids_b, "reference ids are deterministic");

    // And match the slug-derived id helper.
    assert_eq!(first[0].id, reference_profile_id("claude"));
}

// ---------------------------------------------------------------------------
// LOT D0 (§17.3) — structured_adapter sur les profils de référence
// ---------------------------------------------------------------------------

#[test]
fn catalogue_claude_and_codex_carry_their_structured_adapter() {
    use domain::profile::StructuredAdapter;

    let profiles = reference_profiles();
    let by_command: HashMap<&str, &AgentProfile> =
        profiles.iter().map(|p| (p.command.as_str(), p)).collect();

    // Claude / Codex sont pilotés en mode structuré (cellule chat + AgentSession).
    assert_eq!(
        by_command["claude"].structured_adapter,
        Some(StructuredAdapter::Claude),
        "Claude reference profile must declare the Claude structured adapter"
    );
    assert_eq!(
        by_command["codex"].structured_adapter,
        Some(StructuredAdapter::Codex),
        "Codex reference profile must declare the Codex structured adapter"
    );
    assert_eq!(
        by_command["opencode"].structured_adapter,
        Some(StructuredAdapter::OpenCode),
        "OpenCode reference profile must declare the OpenCode structured adapter"
    );
}

#[test]
fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
    // §17.3 : les profils non encore couverts restent TUI/PTY (pas d'adapter).
    let profiles = reference_profiles();
    let by_command: HashMap<&str, &AgentProfile> =
        profiles.iter().map(|p| (p.command.as_str(), p)).collect();

    assert_eq!(by_command["gemini"].structured_adapter, None);
    assert_eq!(by_command["aider"].structured_adapter, None);
}

#[test]
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
    let claude = claude_model_catalogue();
    let codex = codex_model_catalogue();

    assert!(claude
        .iter()
        .any(|model| model.model_id == "claude-sonnet-5" && model.recommended));
    assert!(codex
        .iter()
        .any(|model| model.model_id == "gpt-5-codex" && model.recommended));
    assert!(claude
        .iter()
        .all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty()));
    assert!(codex
        .iter()
        .all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty()));
}
