//! Round 20 near-90 raw integration coverage closures.
//!
//! All fixtures are local and deterministic: temp workspaces, loopback HTTP,
//! and a fake `gh` binary. Run with `--test-threads=1`; several covered
//! surfaces resolve config/workspace through process environment.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration as StdDuration;

use chrono::Utc;
use openhuman_core::openhuman::app_state::{
    snapshot, update_local_state, StoredAppStatePatch, StoredOnboardingTasks,
};
use openhuman_core::openhuman::config::rpc as config_rpc;
use openhuman_core::openhuman::credentials::profiles::{
    AuthProfile, AuthProfileKind, AuthProfilesStore,
};
use openhuman_core::openhuman::credentials::{
    AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
};
use openhuman_core::openhuman::memory::{
    ai_list_memory_files, ai_read_memory_file, ai_write_memory_file, clear_namespace,
    context_query, context_recall, doc_delete, doc_list, doc_put, memory_delete_document,
    memory_init, memory_list_documents, memory_list_namespaces, memory_query_namespace,
    memory_recall_context, memory_recall_memories, namespace_list, ClearNamespaceParams,
    DeleteDocParams, EmptyRequest, ListDocumentsRequest, ListMemoryFilesRequest, MemoryInitRequest,
    PutDocParams, QueryNamespaceParams, QueryNamespaceRequest, ReadMemoryFileRequest,
    RecallContextRequest, RecallMemoriesRequest, RecallNamespaceParams, WriteMemoryFileRequest,
};
use openhuman_core::openhuman::memory_sources::readers::SourceReader;
use openhuman_core::openhuman::memory_sources::sync::sync_source;
use openhuman_core::openhuman::memory_sources::{ContentType, MemorySourceEntry, SourceKind};
use openhuman_core::openhuman::threads::ops as thread_ops;
use openhuman_core::openhuman::threads::welcome_migration::migrate_welcome_agent_artifacts;
use serde_json::{json, Value};
use tempfile::{Builder, TempDir};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

static ROUND20_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

struct EnvGuard {
    key: &'static str,
    old: Option<String>,
}

impl EnvGuard {
    fn set(key: &'static str, value: impl Into<String>) -> Self {
        let old = std::env::var(key).ok();
        unsafe { std::env::set_var(key, value.into()) };
        Self { key, old }
    }

    fn set_path(key: &'static str, path: &Path) -> Self {
        Self::set(key, path.to_string_lossy().into_owned())
    }

    fn unset(key: &'static str) -> Self {
        let old = std::env::var(key).ok();
        unsafe { std::env::remove_var(key) };
        Self { key, old }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        match &self.old {
            Some(value) => unsafe { std::env::set_var(self.key, value) },
            None => unsafe { std::env::remove_var(self.key) },
        }
    }
}

struct Harness {
    _tmp: TempDir,
    root: PathBuf,
    _guards: Vec<EnvGuard>,
}

impl Harness {
    async fn config(&self) -> openhuman_core::openhuman::config::Config {
        config_rpc::load_config_with_timeout()
            .await
            .expect("isolated config should load")
    }

    fn workspace_dir(&self) -> PathBuf {
        self.root.join("workspace")
    }

    fn state_dir(&self) -> PathBuf {
        self.workspace_dir().join("state")
    }

    fn app_state_file(&self) -> PathBuf {
        self.state_dir().join("app-state.json")
    }
}

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
    ROUND20_ENV_LOCK
        .get_or_init(|| Mutex::new(()))
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

fn tempdir() -> TempDir {
    std::fs::create_dir_all("target").expect("create target");
    Builder::new()
        .prefix("near90-closure-round20-")
        .tempdir_in("target")
        .expect("round20 tempdir")
}

fn write_min_config(root: &Path, api_url: &str) {
    std::fs::create_dir_all(root).expect("create config root");
    let cfg = format!(
        r#"api_url = "{api_url}"
default_model = "round20-coverage-model"
default_temperature = 0.2
onboarding_completed = true
chat_onboarding_completed = false

[observability]
analytics_enabled = false

[secrets]
encrypt = false

[meet]
auto_orchestrator_handoff = false

[local_ai]
enabled = false
runtime_enabled = false
opt_in_confirmed = false

[memory]
provider = "none"
embedding_provider = "none"
embedding_model = "none"
embedding_dimensions = 0
auto_save = false

[memory_tree]
embedding_strict = false
"#
    );
    std::fs::write(root.join("config.toml"), &cfg).expect("write config.toml");
    let _: openhuman_core::openhuman::config::Config =
        toml::from_str(&cfg).expect("round20 config must match schema");
}

fn setup(api_url: &str) -> Harness {
    let tmp = tempdir();
    let root = tmp.path().join("openhuman");
    write_min_config(&root, api_url);
    let guards = vec![
        EnvGuard::set_path("OPENHUMAN_WORKSPACE", &root),
        EnvGuard::set_path("HOME", tmp.path()),
        EnvGuard::unset("BACKEND_URL"),
        EnvGuard::unset("VITE_BACKEND_URL"),
        EnvGuard::unset("OPENHUMAN_API_URL"),
        EnvGuard::unset("OPENHUMAN_CORE_RPC_URL"),
        EnvGuard::unset("OPENHUMAN_CORE_PORT"),
        EnvGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
        EnvGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
        EnvGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
        EnvGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
    ];

    Harness {
        _tmp: tmp,
        root,
        _guards: guards,
    }
}

fn source_entry(id: &str, kind: SourceKind) -> MemorySourceEntry {
    MemorySourceEntry {
        id: id.to_string(),
        kind,
        label: format!("{id} label"),
        enabled: true,
        toolkit: None,
        connection_id: None,
        path: None,
        glob: None,
        url: None,
        branch: None,
        paths: Vec::new(),
        query: None,
        since_days: None,
        max_items: None,
        max_commits: None,
        max_issues: None,
        max_prs: None,
        selector: None,
        max_tokens_per_sync: None,
        max_cost_per_sync_usd: None,
        sync_depth_days: None,
    }
}

async fn auth_me_server(
    status: &'static str,
    body: &'static str,
) -> (String, tokio::task::JoinHandle<()>) {
    let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
        .await
        .expect("bind auth fixture");
    let url = format!("http://{}", listener.local_addr().expect("listener addr"));
    let task = tokio::spawn(async move {
        while let Ok((mut stream, _)) = listener.accept().await {
            let mut req = [0_u8; 2048];
            let _ = stream.read(&mut req).await;
            let response = format!(
                "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = stream.write_all(response.as_bytes()).await;
            let _ = stream.shutdown().await;
        }
    });
    (url, task)
}

#[tokio::test]
async fn round20_app_state_quarantines_directory_state_and_uses_stored_user_on_http_error() {
    let _lock = env_lock();
    let (api_url, server) = auth_me_server("503 Service Unavailable", r#"{"error":"down"}"#).await;
    let harness = setup(&api_url);
    let config = harness.config().await;

    AuthService::from_config(&config)
        .store_provider_token(
            APP_SESSION_PROVIDER,
            DEFAULT_AUTH_PROFILE_NAME,
            "round20.remote.token",
            HashMap::from([
                ("user_id".to_string(), "stored-round20".to_string()),
                (
                    "user_json".to_string(),
                    json!({"id":"stored-round20","fullName":"Stored Round20"}).to_string(),
                ),
            ]),
            true,
        )
        .expect("seed app session");

    let first = update_local_state(StoredAppStatePatch {
        keyring_consent: None,
        encryption_key: None,
        onboarding_tasks: Some(Some(StoredOnboardingTasks {
            accessibility_permission_granted: false,
            local_model_consent_given: true,
            local_model_download_started: false,
            enabled_tools: vec!["github".to_string()],
            connected_sources: vec!["rss".to_string()],
            updated_at_ms: Some(20),
        })),
    })
    .await
    .expect("write tasks")
    .value;
    assert!(first.encryption_key.is_none());
    assert_eq!(
        first.onboarding_tasks.expect("tasks").enabled_tools,
        vec!["github"]
    );

    let snap = snapshot().await.expect("snapshot").value;
    assert_eq!(
        snap.current_user.as_ref().and_then(|v| v.get("id")),
        Some(&json!("stored-round20"))
    );
    assert_eq!(snap.session_token.as_deref(), Some("round20.remote.token"));
    assert!(!snap.analytics_enabled);
    assert!(!snap.meet_auto_orchestrator_handoff);

    std::fs::remove_file(harness.app_state_file()).expect("remove app-state file");
    std::fs::create_dir_all(harness.app_state_file()).expect("directory at app-state path");
    let recovered = snapshot()
        .await
        .expect("snapshot should quarantine unreadable app-state path")
        .value;
    assert!(recovered.local_state.onboarding_tasks.is_none());
    assert!(!harness.app_state_file().exists());
    assert!(
        std::fs::read_dir(harness.state_dir())
            .expect("state dir")
            .filter_map(Result::ok)
            .any(|entry| entry
                .file_name()
                .to_string_lossy()
                .contains("json.corrupted")),
        "directory app-state should be moved aside"
    );

    server.abort();
}

#[test]
fn round20_credentials_profiles_cover_legacy_plaintext_errors_and_active_edges() {
    let _lock = env_lock();
    let harness = setup("http://127.0.0.1:9");
    let state_dir = harness.root.join("profile-store");
    let store = AuthProfilesStore::new(&state_dir, false);

    let token = AuthProfile::new_token("linear", "team", "lin-round20".to_string());
    store
        .upsert_profile(token.clone(), false)
        .expect("insert token");
    let inactive = store.load().expect("load inactive token");
    assert!(!inactive.active_profiles.contains_key("linear"));
    store
        .set_active_profile("linear", &token.id)
        .expect("activate token");
    assert_eq!(
        store
            .load()
            .expect("load active")
            .active_profiles
            .get("linear"),
        Some(&token.id)
    );

    let path = store.path().to_path_buf();
    let now = Utc::now().to_rfc3339();
    std::fs::write(
        &path,
        serde_json::to_string_pretty(&json!({
            "schema_version": 1,
            "updated_at": now,
            "active_profiles": { "github": "legacy-oauth", "token": "legacy-token" },
            "profiles": {
                "legacy-token": {
                    "provider": "token",
                    "profile_name": "plain",
                    "kind": "token",
                    "token": "plain-secret",
                    "metadata": { "k": "v" },
                    "created_at": "bad-created",
                    "updated_at": "bad-updated"
                },
                "legacy-oauth": {
                    "provider": "github",
                    "profile_name": "oauth",
                    "kind": "oauth",
                    "access_token": "plain-access",
                    "refresh_token": "plain-refresh",
                    "id_token": "",
                    "expires_at": null,
                    "token_type": "Bearer",
                    "scope": "repo",
                    "metadata": {},
                    "created_at": "2026-05-29T00:00:00Z",
                    "updated_at": "2026-05-29T00:00:00Z"
                }
            }
        }))
        .expect("profile json"),
    )
    .expect("write legacy plaintext store");

    let loaded = store.load().expect("load plaintext legacy profiles");
    assert_eq!(
        loaded
            .profiles
            .get("legacy-token")
            .and_then(|profile| profile.token.as_deref()),
        Some("plain-secret")
    );
    let oauth = loaded.profiles.get("legacy-oauth").expect("oauth loaded");
    assert_eq!(oauth.kind, AuthProfileKind::OAuth);
    assert_eq!(
        oauth.token_set.as_ref().map(|tokens| (
            tokens.access_token.as_str(),
            tokens.refresh_token.as_deref(),
            tokens.expires_at
        )),
        Some(("plain-access", Some("plain-refresh"), None))
    );

    let set_active_err = store
        .set_active_profile("missing", "not-there")
        .expect_err("missing active profile")
        .to_string();
    assert!(set_active_err.contains("Auth profile not found"));
}

#[tokio::test]
async fn round20_memory_sources_readers_and_sync_cover_error_edges_without_network() {
    let _lock = env_lock();
    let harness = setup("http://127.0.0.1:9");
    let config = harness.config().await;

    let rss = openhuman_core::openhuman::memory_sources::readers::rss::RssReader;
    let mut missing_url = source_entry("rss-missing-url", SourceKind::RssFeed);
    assert_eq!(
        rss.list_items(&missing_url, &config)
            .await
            .expect_err("rss url required"),
        "rss source requires a url"
    );

    let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
        .await
        .expect("bind feed fixture");
    let feed_url = format!("http://{}", listener.local_addr().expect("addr"));
    let server = tokio::spawn(async move {
        if let Ok((mut stream, _)) = listener.accept().await {
            let mut req = [0_u8; 1024];
            let _ = stream.read(&mut req).await;
            let response =
                "HTTP/1.1 200 OK\r\ncontent-type: text/xml\r\ncontent-length: 10\r\n\r\nnot-a-feed";
            let _ = stream.write_all(response.as_bytes()).await;
            let _ = stream.shutdown().await;
        }
    });
    missing_url.url = Some(feed_url);
    let feed_err = rss
        .list_items(&missing_url, &config)
        .await
        .expect_err("unrecognized feed rejected");
    assert!(feed_err.contains("unrecognized feed format"));
    let _ = server.await;

    // GitHub reader portion requires a real `gh` on PATH to shadow with our
    // fake. Skip on CI containers that lack `gh` — without it the reader
    // falls through to the real GitHub API and rate-limits.
    let gh_available = std::process::Command::new("gh")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    let tmp = tempdir();
    let bin = tmp.path().join("bin");
    std::fs::create_dir_all(&bin).expect("bin dir");
    let script = bin.join("gh");
    write_fake_gh_round20(&script);
    let git_stub = bin.join("git");
    std::fs::write(&git_stub, "#!/usr/bin/env bash\nexit 1\n").expect("write fake git");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&git_stub)
            .expect("metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&git_stub, perms).expect("chmod fake git");
    }
    let old_path = std::env::var("PATH").unwrap_or_default();
    let _path = EnvGuard::set("PATH", format!("{}:{old_path}", bin.display()));

    let github = openhuman_core::openhuman::memory_sources::readers::github::GithubReader;
    let mut entry = source_entry("github-round20", SourceKind::GithubRepo);
    entry.url = Some("git@github.com:tinyhumansai/openhuman.git".to_string());
    if !gh_available {
        eprintln!("skipping github reader assertions: gh CLI not available");
    } else {
        let items = github
            .list_items(&entry, &config)
            .await
            .expect("github list via fake gh");
        assert!(items.iter().any(|item| item.id == "commit:def456"));
        assert!(items.iter().any(|item| item.id == "issue:20"));

        let pr = github
            .read_item(&entry, "pr:21", &config)
            .await
            .expect("read merged pr");
        assert_eq!(pr.content_type, ContentType::Markdown);
        assert!(pr.body.contains("merged at 2026-05-29T01:00:00Z"));
        assert_eq!(
            pr.metadata.get("merged").and_then(Value::as_bool),
            Some(true)
        );

        let bad_issue = github
            .read_item(&entry, "issue:not-a-number", &config)
            .await
            .expect_err("bad issue number");
        assert!(bad_issue.contains("invalid issue number"));
    }

    let mut disabled = source_entry("disabled-twitter", SourceKind::TwitterQuery);
    disabled.enabled = false;
    let disabled_err = sync_source(disabled, config.clone())
        .await
        .expect_err("disabled sync rejected");
    assert!(disabled_err.contains("is disabled"));

    let twitter = source_entry("twitter-round20", SourceKind::TwitterQuery);
    sync_source(twitter, config)
        .await
        .expect("twitter placeholder is reported by background task");
    tokio::time::sleep(StdDuration::from_millis(25)).await;
}

#[tokio::test]
async fn round20_memory_documents_files_and_envelopes_cover_success_and_failure_paths() {
    let _lock = env_lock();
    let harness = setup("http://127.0.0.1:9");

    let init = memory_init(MemoryInitRequest {
        jwt_token: Some("ignored-round20".to_string()),
    })
    .await
    .expect("memory init")
    .value
    .data
    .expect("init data");
    assert!(init.initialized);
    assert!(init.memory_dir.ends_with("/memory"));

    ai_write_memory_file(WriteMemoryFileRequest {
        relative_path: "notes/round20.md".to_string(),
        content: "Round20 local file memory".to_string(),
    })
    .await
    .expect("write memory file");
    let read = ai_read_memory_file(ReadMemoryFileRequest {
        relative_path: "notes/round20.md".to_string(),
    })
    .await
    .expect("read memory file")
    .value
    .data
    .expect("read data");
    assert!(read.content.contains("local file memory"));
    let traversal = ai_write_memory_file(WriteMemoryFileRequest {
        relative_path: "../escape.md".to_string(),
        content: "no".to_string(),
    })
    .await
    .expect_err("traversal rejected");
    assert!(traversal.contains("path traversal"));
    let listed = ai_list_memory_files(ListMemoryFilesRequest {
        relative_dir: "notes".to_string(),
    })
    .await
    .expect("list notes")
    .value
    .data
    .expect("list data");
    assert_eq!(listed.files, vec!["round20.md"]);

    // Use a deterministic namespace — random UUIDs can trigger PII detection.
    let namespace = "round20-cov-test-ns".to_string();
    let put = doc_put(PutDocParams {
        namespace: namespace.clone(),
        key: "launch-note".to_string(),
        title: "Launch Note".to_string(),
        content: "The Calypso launch depends on QA, design, and release owners.".to_string(),
        source_type: "note".to_string(),
        priority: "high".to_string(),
        tags: vec!["round20".to_string()],
        metadata: json!({"round": 20}),
        category: "core".to_string(),
        session_id: Some("session-round20".to_string()),
        document_id: Some("doc-round20".to_string()),
    })
    .await
    .expect("put doc")
    .value;
    assert_eq!(put.document_id, "doc-round20");

    let docs = doc_list(None)
        .await
        .expect("doc list all")
        .value
        .get("documents")
        .and_then(Value::as_array)
        .cloned()
        .expect("documents");
    assert!(docs.iter().any(|doc| doc["documentId"] == "doc-round20"));
    let namespaces = namespace_list().await.expect("namespace list").value;
    assert!(namespaces.contains(&namespace));

    let query = context_query(QueryNamespaceParams {
        namespace: namespace.clone(),
        query: "Calypso QA".to_string(),
        limit: Some(3),
    })
    .await
    .expect("context query")
    .value;
    assert!(query.contains("Calypso") || query.contains("launch"));
    let recall = context_recall(RecallNamespaceParams {
        namespace: namespace.clone(),
        limit: Some(3),
    })
    .await
    .expect("context recall")
    .value;
    assert!(recall.as_deref().unwrap_or_default().contains("Calypso"));

    let envelope_docs = memory_list_documents(ListDocumentsRequest {
        namespace: Some(namespace.clone()),
    })
    .await
    .expect("memory list documents")
    .value
    .data
    .expect("list documents data");
    assert_eq!(envelope_docs.count, 1);
    let envelope_namespaces = memory_list_namespaces(EmptyRequest {})
        .await
        .expect("memory namespaces")
        .value
        .data
        .expect("namespaces data");
    assert!(envelope_namespaces.namespaces.contains(&namespace));

    let hidden_context = memory_query_namespace(QueryNamespaceRequest {
        namespace: namespace.clone(),
        query: "Calypso".to_string(),
        include_references: Some(false),
        document_ids: Some(vec!["doc-round20".to_string()]),
        limit: Some(1),
        max_chunks: Some(2),
    })
    .await
    .expect("query namespace envelope")
    .value
    .data
    .expect("query namespace data");
    assert!(hidden_context.context.is_none());
    assert!(hidden_context.llm_context_message.is_some());

    let recall_context = memory_recall_context(RecallContextRequest {
        namespace: namespace.clone(),
        include_references: Some(true),
        limit: None,
        max_chunks: Some(1),
    })
    .await
    .expect("recall context envelope")
    .value
    .data
    .expect("recall context data");
    assert!(recall_context.llm_context_message.is_some());

    let memories = memory_recall_memories(RecallMemoriesRequest {
        namespace: namespace.clone(),
        min_retention: Some(0.0),
        as_of: Some(0.0),
        limit: Some(1),
        max_chunks: None,
        top_k: Some(1),
    })
    .await
    .expect("recall memories")
    .value
    .data
    .expect("memories data");
    assert!(!memories.memories.is_empty());

    let deleted =
        memory_delete_document(openhuman_core::openhuman::memory::DeleteDocumentRequest {
            namespace: namespace.clone(),
            document_id: "doc-round20".to_string(),
        })
        .await
        .expect("delete document envelope")
        .value
        .data
        .expect("delete data");
    assert!(deleted.deleted);
    let second_delete = doc_delete(DeleteDocParams {
        namespace: namespace.clone(),
        document_id: "doc-round20".to_string(),
    })
    .await
    .expect("direct delete missing")
    .value;
    assert_eq!(second_delete["deleted"], false);
    let cleared = clear_namespace(ClearNamespaceParams { namespace })
        .await
        .expect("clear namespace")
        .value;
    assert!(cleared.cleared);

    let _ = harness;
}

#[tokio::test]
async fn round20_threads_fallback_title_delete_missing_and_welcome_noop_paths() {
    let _lock = env_lock();
    let harness = setup("http://127.0.0.1:9");

    let created = thread_ops::thread_create_new(
        openhuman_core::openhuman::memory::CreateConversationThreadRequest {
            labels: None,
            personality_id: None,
        },
    )
    .await
    .expect("create thread")
    .value
    .data
    .expect("created data");

    let user_message = openhuman_core::openhuman::memory::ConversationMessageRecord {
        id: "round20-user-msg".to_string(),
        content: "Please map the onboarding telemetry rollout across product analytics and QA."
            .to_string(),
        message_type: "text".to_string(),
        extra_metadata: Value::Null,
        sender: "user".to_string(),
        created_at: Utc::now().to_rfc3339(),
    };
    thread_ops::message_append(
        openhuman_core::openhuman::memory::AppendConversationMessageRequest {
            thread_id: created.id.clone(),
            message: user_message,
        },
    )
    .await
    .expect("append user");

    let fallback = thread_ops::thread_generate_title(
        openhuman_core::openhuman::memory::GenerateConversationThreadTitleRequest {
            thread_id: created.id.clone(),
            assistant_message: None,
        },
    )
    .await
    .expect("fallback title")
    .value
    .data
    .expect("fallback data");
    assert!(!fallback.title.trim().is_empty());
    assert_ne!(fallback.title, created.title);

    let missing_update = thread_ops::message_update(
        openhuman_core::openhuman::memory::UpdateConversationMessageRequest {
            thread_id: created.id.clone(),
            message_id: "missing-message".to_string(),
            extra_metadata: Some(json!({"x": true})),
        },
    )
    .await
    .expect_err("missing message update fails");
    assert!(missing_update.contains("message") || missing_update.contains("not found"));

    let missing_delete = thread_ops::thread_delete(
        openhuman_core::openhuman::memory::DeleteConversationThreadRequest {
            thread_id: "missing-thread-round20".to_string(),
            deleted_at: Utc::now().to_rfc3339(),
        },
    )
    .await
    .expect("missing thread delete is idempotent")
    .value
    .data
    .expect("delete data");
    assert!(!missing_delete.deleted);

    std::fs::create_dir_all(harness.workspace_dir().join("session_raw")).expect("raw dir");
    std::fs::write(
        harness.workspace_dir().join("session_raw/ignore.jsonl"),
        "{\"_meta\":{\"agent\":\"orchestrator\",\"thread_id\":\"t\"}}\n",
    )
    .expect("write non-welcome transcript");
    let migration =
        migrate_welcome_agent_artifacts(&harness.workspace_dir()).expect("noop migration");
    assert_eq!(migration.threads_updated, 0);
    assert_eq!(migration.transcripts_updated, 0);
}

fn write_fake_gh_round20(path: &PathBuf) {
    let script = r#"#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--version" ]]; then
  echo "gh version 2.0.0"
  exit 0
fi
if [[ "${1:-}" != "api" ]]; then
  echo "unsupported gh command" >&2
  exit 2
fi
case "${2:-}" in
  repos/tinyhumansai/openhuman/commits\?*)
    cat <<'JSON'
[{"sha":"def456","commit":{"message":"Round20 commit fixture","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-29T00:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-29T00:00:00Z"}}}]
JSON
    ;;
  repos/tinyhumansai/openhuman/issues\?*)
    cat <<'JSON'
[{"number":20,"title":"Round20 issue","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T00:30:00Z","pull_request":null}]
JSON
    ;;
  repos/tinyhumansai/openhuman/pulls\?*)
    cat <<'JSON'
[{"number":21,"title":"Round20 merged PR","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T01:00:00Z","merged_at":"2026-05-29T01:00:00Z","comments":0}]
JSON
    ;;
  repos/tinyhumansai/openhuman/pulls/21)
    cat <<'JSON'
{"number":21,"title":"Round20 merged PR","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T01:00:00Z","merged_at":"2026-05-29T01:00:00Z","comments":0}
JSON
    ;;
  repos/tinyhumansai/openhuman/issues/21/comments\?*)
    cat <<'JSON'
[]
JSON
    ;;
  *)
    echo "unexpected gh api path: ${2:-}" >&2
    exit 3
    ;;
esac
"#;
    std::fs::write(path, script).expect("write fake gh");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(path)
            .expect("fake gh metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(path, perms).expect("chmod fake gh");
    }
}
