//! Focused raw integration coverage for memory sync + memory sources.
//!
//! Everything here is local: temp workspaces, loopback HTTP, and a fake `gh`
//! binary. Run with `--test-threads=1` because config and PATH are process
//! globals.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};

use axum::extract::Request;
use axum::response::IntoResponse;
use axum::routing::any;
use axum::{Json, Router};
use serde_json::{json, Value};
use tempfile::TempDir;

use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::credentials::{
    AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
};
use openhuman_core::openhuman::memory_sources::readers::SourceReader;
use openhuman_core::openhuman::memory_sources::{
    add_source, get_source, list_enabled_by_kind, list_sources,
    remove_composio_source_by_connection_id, remove_source, update_source, upsert_composio_source,
    MemorySourceEntry, MemorySourcePatch, SourceKind,
};
use openhuman_core::openhuman::memory_sync::composio::bus::{
    ComposioConfigChangedSubscriber, ComposioConnectionCreatedSubscriber, ComposioTriggerSubscriber,
};
use openhuman_core::openhuman::memory_sync::composio::providers::clickup::ClickUpProvider;
use openhuman_core::openhuman::memory_sync::composio::providers::github::GitHubProvider;
use openhuman_core::openhuman::memory_sync::composio::providers::gmail::GmailProvider;
use openhuman_core::openhuman::memory_sync::composio::providers::slack::{
    run_backfill_via_search, SlackProvider,
};
use openhuman_core::openhuman::memory_sync::composio::providers::{
    ComposioProvider, ProviderContext, SyncReason, TaskFetchFilter,
};
use openhuman_core::openhuman::memory_sync::composio::{
    all_composio_sync_providers, get_composio_sync_provider, init_default_composio_sync_providers,
};

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

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

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, value: &Path) -> Self {
        Self::set(key, value.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) },
        }
    }
}

fn config_in(tmp: &TempDir) -> Config {
    let mut config = Config {
        config_path: tmp.path().join("config.toml"),
        workspace_dir: tmp.path().join("workspace"),
        action_dir: tmp.path().join("workspace"),
        ..Config::default()
    };
    config.secrets.encrypt = false;
    config
}

async fn persist_config(config: &Config) {
    std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir");
    config.save().await.expect("save config");
}

fn source(kind: SourceKind, id: &str) -> 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 loopback_router(router: Router) -> (String, tokio::task::JoinHandle<()>) {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind loopback");
    let addr = listener.local_addr().expect("loopback addr");
    let handle = tokio::spawn(async move {
        axum::serve(listener, router).await.expect("serve loopback");
    });
    (format!("http://{addr}"), handle)
}

#[tokio::test]
async fn memory_sources_registry_persists_crud_and_composio_upserts() {
    let _guard = env_lock();
    let tmp = TempDir::new().expect("tempdir");
    let config = config_in(&tmp);
    let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
    let _home = EnvGuard::set_path("HOME", tmp.path());
    let _backend = EnvGuard::unset("BACKEND_URL");
    persist_config(&config).await;

    let mut folder = source(SourceKind::Folder, "src_folder_round15");
    folder.path = Some(tmp.path().join("notes").to_string_lossy().into_owned());
    folder.glob = Some("**/*.md".to_string());
    let added = add_source(folder.clone()).await.expect("add folder");
    assert_eq!(added.id, folder.id);

    let duplicate = add_source(folder.clone())
        .await
        .expect_err("duplicate id rejected");
    assert!(duplicate.contains("already exists"));

    let enabled_folders = list_enabled_by_kind(SourceKind::Folder)
        .await
        .expect("enabled folders");
    assert_eq!(enabled_folders.len(), 1);

    let updated = update_source(
        &folder.id,
        MemorySourcePatch {
            label: Some("Renamed notes".to_string()),
            enabled: Some(false),
            glob: Some("*.txt".to_string()),
            ..MemorySourcePatch::default()
        },
    )
    .await
    .expect("update folder");
    assert_eq!(updated.label, "Renamed notes");
    assert!(!updated.enabled);
    assert_eq!(updated.glob.as_deref(), Some("*.txt"));

    let none_enabled = list_enabled_by_kind(SourceKind::Folder)
        .await
        .expect("disabled folder filtered");
    assert!(none_enabled.is_empty());

    let first = upsert_composio_source("slack", "conn-round15", "Slack workspace")
        .await
        .expect("insert composio source");
    assert_eq!(first.toolkit.as_deref(), Some("slack"));
    let second = upsert_composio_source("slack", "conn-round15", "Slack renamed")
        .await
        .expect("update composio source");
    assert_eq!(second.id, first.id);
    assert_eq!(second.label, "Slack renamed");

    let fetched = get_source(&first.id)
        .await
        .expect("get source")
        .expect("source exists");
    assert_eq!(fetched.connection_id.as_deref(), Some("conn-round15"));

    assert!(remove_source(&folder.id).await.expect("remove folder"));
    assert!(!remove_source("missing-source")
        .await
        .expect("remove missing"));
    let all = list_sources().await.expect("list sources");
    assert_eq!(all.len(), 1);
    assert_eq!(all[0].id, first.id);
}

#[tokio::test]
async fn remove_composio_source_by_connection_id_prunes_on_disconnect_and_survives_reconnect() {
    let _guard = env_lock();
    let tmp = TempDir::new().expect("tempdir");
    let config = config_in(&tmp);
    let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
    let _home = EnvGuard::set_path("HOME", tmp.path());
    let _backend = EnvGuard::unset("BACKEND_URL");
    persist_config(&config).await;

    // Two live composio connections plus an unrelated folder source.
    let gmail_old = upsert_composio_source("gmail", "conn-old", "Gmail · conn-old")
        .await
        .expect("insert gmail");
    upsert_composio_source("slack", "conn-slack", "Slack")
        .await
        .expect("insert slack");
    let mut folder = source(SourceKind::Folder, "src_folder_disc");
    folder.path = Some(tmp.path().join("notes").to_string_lossy().into_owned());
    folder.glob = Some("**/*.md".to_string());
    add_source(folder.clone()).await.expect("add folder");

    // No-match is a no-op (returns 0, removes nothing).
    assert_eq!(
        remove_composio_source_by_connection_id("conn-does-not-exist")
            .await
            .expect("no-match remove"),
        0
    );
    assert_eq!(list_sources().await.expect("list").len(), 3);

    // Disconnect: prune ONLY the matching composio source, by connection_id.
    assert_eq!(
        remove_composio_source_by_connection_id("conn-old")
            .await
            .expect("prune on disconnect"),
        1
    );
    let after_disconnect = list_sources().await.expect("list after disconnect");
    assert_eq!(after_disconnect.len(), 2);
    assert!(
        after_disconnect.iter().all(|s| s.id != gmail_old.id),
        "old gmail entry must be gone"
    );
    assert!(
        after_disconnect
            .iter()
            .any(|s| s.connection_id.as_deref() == Some("conn-slack")),
        "the other composio connection must be untouched"
    );
    assert!(
        after_disconnect.iter().any(|s| s.id == folder.id),
        "non-composio folder source must be untouched"
    );

    // Reconnect: backend mints a NEW connection_id for the same Gmail account.
    // upsert inserts a fresh entry; no stale duplicate is left behind.
    let gmail_new = upsert_composio_source("gmail", "conn-new", "Gmail · conn-new")
        .await
        .expect("reconnect gmail");
    assert_ne!(gmail_new.id, gmail_old.id);
    let final_sources = list_sources().await.expect("final list");
    let gmail_entries: Vec<_> = final_sources
        .iter()
        .filter(|s| s.toolkit.as_deref() == Some("gmail"))
        .collect();
    assert_eq!(
        gmail_entries.len(),
        1,
        "exactly one gmail source after reconnect — no orphan"
    );
    assert_eq!(gmail_entries[0].connection_id.as_deref(), Some("conn-new"));
}

#[tokio::test]
async fn rss_reader_lists_reads_and_reports_feed_errors_from_loopback() {
    let _guard = env_lock();
    let tmp = TempDir::new().expect("tempdir");
    let config = config_in(&tmp);
    let rss_xml = r#"<?xml version="1.0"?>
    <rss version="2.0"><channel>
      <item>
        <title>First &amp; useful</title>
        <link>https://example.test/first</link>
        <description><![CDATA[<p>HTML body &amp; details</p>]]></description>
        <pubDate>Fri, 29 May 2026 10:00:00 GMT</pubDate>
      </item>
      <item>
        <title>Second</title>
        <guid>guid-second</guid>
        <description>Plain &lt;encoded&gt; body</description>
      </item>
    </channel></rss>"#;
    let atom_xml = r#"<?xml version="1.0"?>
    <feed><entry>
      <title>Atom item</title>
      <id>urn:round15:atom</id>
      <summary>Atom summary</summary>
      <link href="https://example.test/atom" />
      <updated>2026-05-29T12:00:00Z</updated>
    </entry></feed>"#;
    let router = Router::new().route(
        "/{feed}",
        any(move |req: Request| {
            let rss_xml = rss_xml.to_string();
            let atom_xml = atom_xml.to_string();
            async move {
                match req.uri().path() {
                    "/rss" => (
                        [(axum::http::header::CONTENT_TYPE, "application/rss+xml")],
                        rss_xml,
                    )
                        .into_response(),
                    "/atom" => (
                        [(axum::http::header::CONTENT_TYPE, "application/atom+xml")],
                        atom_xml,
                    )
                        .into_response(),
                    "/broken" => (axum::http::StatusCode::BAD_GATEWAY, "bad feed").into_response(),
                    _ => (axum::http::StatusCode::NOT_FOUND, "missing").into_response(),
                }
            }
        }),
    );
    let (base, server) = loopback_router(router).await;

    let reader = openhuman_core::openhuman::memory_sources::readers::rss::RssReader;
    let mut entry = source(SourceKind::RssFeed, "rss-round15");
    entry.url = Some(format!("{base}/rss"));
    entry.max_items = Some(1);

    let items = reader.list_items(&entry, &config).await.expect("list rss");
    assert_eq!(items.len(), 1);
    assert_eq!(items[0].title, "First & useful");

    let content = reader
        .read_item(&entry, "https://example.test/first", &config)
        .await
        .expect("read rss item");
    assert_eq!(content.id, "https://example.test/first");
    assert_eq!(
        content.content_type,
        openhuman_core::openhuman::memory_sources::ContentType::Html
    );
    assert!(content.body.contains("HTML body"));

    let mut atom = entry.clone();
    atom.url = Some(format!("{base}/atom"));
    let atom_content = reader
        .read_item(&atom, "urn:round15:atom", &config)
        .await
        .expect("read atom item");
    assert_eq!(atom_content.title, "Atom item");
    assert_eq!(
        atom_content.metadata.get("link").and_then(Value::as_str),
        Some("https://example.test/atom")
    );

    let missing = reader
        .read_item(&atom, "missing", &config)
        .await
        .expect_err("missing atom item");
    assert!(missing.contains("not found"));

    let mut broken = entry;
    broken.url = Some(format!("{base}/broken"));
    let err = reader
        .list_items(&broken, &config)
        .await
        .expect_err("http status error");
    assert!(err.contains("502"));

    server.abort();
}

#[tokio::test]
async fn github_reader_uses_fake_gh_for_list_and_read_paths() {
    let _guard = env_lock();
    if std::process::Command::new("gh")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| !s.success())
        .unwrap_or(true)
    {
        eprintln!("skipping: gh CLI not available");
        return;
    }
    let tmp = TempDir::new().expect("tempdir");
    let config = config_in(&tmp);
    let bin = tmp.path().join("bin");
    std::fs::create_dir_all(&bin).expect("bin dir");
    let script = bin.join("gh");
    write_fake_gh(&script);
    write_fake_git(&bin.join("git"));
    let old_path = std::env::var("PATH").unwrap_or_default();
    let _path = EnvGuard::set("PATH", format!("{}:{old_path}", bin.display()));

    let reader = openhuman_core::openhuman::memory_sources::readers::github::GithubReader;
    let mut entry = source(SourceKind::GithubRepo, "github-round15");
    entry.url = Some("https://github.com/tinyhumansai/openhuman.git".to_string());
    entry.max_commits = Some(30);
    entry.max_issues = Some(30);
    entry.max_prs = Some(30);

    let items = reader
        .list_items(&entry, &config)
        .await
        .expect("list github activity");
    assert!(items.iter().any(|i| i.id == "commit:abc123"));
    assert!(items.iter().any(|i| i.id == "issue:7"));
    assert!(items.iter().any(|i| i.id == "pr:9"));
    assert!(!items.iter().any(|i| i.id == "issue:99"));

    let commit = reader
        .read_item(&entry, "commit:abc123", &config)
        .await
        .expect("read commit");
    assert!(commit.body.contains("Add coverage hooks"));
    assert_eq!(
        commit.metadata.get("sha").and_then(Value::as_str),
        Some("abc123")
    );

    let issue = reader
        .read_item(&entry, "issue:7", &config)
        .await
        .expect("read issue");
    assert!(issue.body.contains("## Description"));
    assert!(issue.body.contains("Needs fixture coverage"));
    assert_eq!(
        issue.metadata.get("state").and_then(Value::as_str),
        Some("open")
    );

    let pr = reader
        .read_item(&entry, "pr:9", &config)
        .await
        .expect("read pr");
    assert!(pr.body.contains("not merged"));
    assert_eq!(
        pr.metadata.get("merged").and_then(Value::as_bool),
        Some(false)
    );

    let invalid = reader
        .read_item(&entry, "unknown:1", &config)
        .await
        .expect_err("invalid id rejected");
    assert!(invalid.contains("invalid item id"));

    let mut bad_url = entry;
    bad_url.url = Some("https://github.com/tinyhumansai/openhuman/tree/main".to_string());
    let bad = reader
        .list_items(&bad_url, &config)
        .await
        .expect_err("deep link rejected");
    assert!(bad.contains("expected https://github.com/<owner>/<repo>"));
}

#[tokio::test]
async fn composio_providers_fetch_profiles_tasks_and_cover_error_branches() {
    let _guard = env_lock();
    let tmp = TempDir::new().expect("tempdir");
    let mut config = config_in(&tmp);
    let requests: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
    let router = {
        let requests = Arc::clone(&requests);
        Router::new().route(
            "/agent-integrations/composio/execute",
            any(move |Json(body): Json<Value>| {
                let requests = Arc::clone(&requests);
                async move {
                    requests.lock().unwrap().push(body.clone());
                    Json(json!({
                        "success": true,
                        "data": execute_response_for(&body),
                    }))
                }
            }),
        )
    };
    let (base, server) = loopback_router(router).await;
    config.api_url = Some(base);
    persist_config(&config).await;
    let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
    let _home = EnvGuard::set_path("HOME", tmp.path());
    AuthService::from_config(&config)
        .store_provider_token(
            APP_SESSION_PROVIDER,
            DEFAULT_AUTH_PROFILE_NAME,
            "round15-session-token",
            HashMap::new(),
            true,
        )
        .expect("store session token");

    let ctx = ProviderContext {
        config: Arc::new(config.clone()),
        toolkit: "github".to_string(),
        connection_id: Some("conn-github".to_string()),
        usage: Default::default(),
        max_items: None,
        sync_depth_days: None,
    };
    let github = GitHubProvider::new();
    let github_profile = github
        .fetch_user_profile(&ctx)
        .await
        .expect("github profile");
    assert_eq!(github_profile.username.as_deref(), Some("octo-round15"));
    assert_eq!(
        github_profile.display_name.as_deref(),
        Some("Round Fifteen")
    );

    let tasks = github
        .fetch_tasks(
            &ctx,
            &TaskFetchFilter {
                repo: Some("tinyhumansai/openhuman".to_string()),
                labels: vec!["coverage".to_string()],
                state: Some("open".to_string()),
                max: 2,
                ..TaskFetchFilter::default()
            },
        )
        .await
        .expect("github tasks");
    assert_eq!(tasks.len(), 1);
    assert_eq!(tasks[0].provider, "github");
    assert_eq!(tasks[0].labels, vec!["coverage"]);

    let clickup_ctx = ProviderContext {
        toolkit: "clickup".to_string(),
        connection_id: Some("conn-clickup".to_string()),
        ..ctx.clone()
    };
    let clickup = ClickUpProvider::new();
    let clickup_profile = clickup
        .fetch_user_profile(&clickup_ctx)
        .await
        .expect("clickup profile");
    assert_eq!(clickup_profile.username.as_deref(), Some("9988"));
    assert_eq!(clickup_profile.email.as_deref(), Some("click@example.test"));

    let clickup_tasks = clickup
        .fetch_tasks(
            &clickup_ctx,
            &TaskFetchFilter {
                team_id: Some("team_1".to_string()),
                list_id: Some("list_1".to_string()),
                max: 3,
                ..TaskFetchFilter::default()
            },
        )
        .await
        .expect("clickup tasks");
    assert_eq!(clickup_tasks.len(), 1);
    assert_eq!(clickup_tasks[0].provider, "clickup");
    assert_eq!(clickup_tasks[0].priority.as_deref(), Some("high"));

    let gmail = GmailProvider::new();
    let gmail_err = gmail
        .fetch_tasks(&ctx, &TaskFetchFilter::default())
        .await
        .expect_err("gmail has no task surface");
    assert!(gmail_err.contains("no task-fetch surface"));

    let slack = SlackProvider::new();
    assert_eq!(slack.toolkit_slug(), "slack");
    assert_eq!(slack.sync_interval_secs(), Some(15 * 60));
    assert!(slack.curated_tools().is_some());
    slack
        .on_trigger(&ctx, "message.created", &json!({"event": "ignored"}))
        .await
        .expect("slack trigger path is defensive");

    let bad_backfill = run_backfill_via_search(&ctx, 0)
        .await
        .expect_err("zero days rejected");
    assert!(!bad_backfill.trim().is_empty());

    assert!(!requests.lock().unwrap().is_empty());
    server.abort();
}

#[test]
fn composio_provider_registry_and_bus_subscribers_expose_stable_metadata() {
    init_default_composio_sync_providers();
    assert!(get_composio_sync_provider("slack").is_some());
    assert!(get_composio_sync_provider("github").is_some());
    assert!(get_composio_sync_provider("clickup").is_some());
    assert!(get_composio_sync_provider("missing").is_none());
    assert!(
        all_composio_sync_providers().len() >= 6,
        "default providers should include gmail/notion/slack/clickup/github/linear"
    );

    let trigger = ComposioTriggerSubscriber::new();
    let connection = ComposioConnectionCreatedSubscriber::new();
    let config_changed = ComposioConfigChangedSubscriber::new();
    assert_eq!(
        openhuman_core::core::event_bus::EventHandler::name(&trigger),
        "composio::trigger"
    );
    assert_eq!(
        openhuman_core::core::event_bus::EventHandler::domains(&trigger),
        Some(&["composio"][..])
    );
    assert_eq!(
        openhuman_core::core::event_bus::EventHandler::name(&connection),
        "composio::connection_created"
    );
    assert_eq!(
        openhuman_core::core::event_bus::EventHandler::name(&config_changed),
        "composio::config_changed"
    );

    for reason in [
        SyncReason::ConnectionCreated,
        SyncReason::Periodic,
        SyncReason::Manual,
    ] {
        assert!(!reason.as_str().is_empty());
    }
}

fn execute_response_for(body: &Value) -> Value {
    let tool = body.get("tool").and_then(Value::as_str).unwrap_or_default();
    let args = body.get("arguments").cloned().unwrap_or_else(|| json!({}));
    let data = match tool {
        "GITHUB_GET_THE_AUTHENTICATED_USER" => json!({
            "login": "octo-round15",
            "name": "Round Fifteen",
            "email": "octo@example.test",
            "avatar_url": "https://example.test/avatar.png",
            "html_url": "https://github.com/octo-round15"
        }),
        "GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS" => json!({
            "items": [{
                "id": 1701,
                "number": 17,
                "title": "Cover provider task normalization",
                "body": "Add deterministic task fixture coverage",
                "html_url": "https://github.com/tinyhumansai/openhuman/issues/17",
                "state": "open",
                "updated_at": "2026-05-29T12:34:56Z",
                "labels": [{"name": "coverage"}],
                "user": {"login": "octo-round15"}
            }],
            "total_count": 1,
            "arguments_echo": args
        }),
        "CLICKUP_GET_AUTHORIZED_USER" => json!({
            "user": {
                "id": 9988,
                "username": "Click Round",
                "email": "click@example.test",
                "profilePicture": "https://example.test/click.png"
            }
        }),
        "CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES" => json!({
            "teams": [{ "id": "team_1", "name": "Coverage Team" }]
        }),
        "CLICKUP_GET_FILTERED_TEAM_TASKS" => json!({
            "tasks": [{
                "id": "task_1",
                "name": "Exercise ClickUp normalization",
                "description": "Task body",
                "url": "https://app.clickup.com/t/task_1",
                "status": {"status": "in progress"},
                "assignees": [{"username": "Click Round"}],
                "date_updated": "1780046400000",
                "priority": {"priority": "high"},
                "tags": [{"name": "coverage"}]
            }],
            "arguments_echo": args
        }),
        _ => json!({ "tool": tool, "unhandled": true }),
    };
    json!({
        "data": data,
        "successful": true,
        "error": null,
        "costUsd": 0.0
    })
}

fn write_fake_gh(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":"abc123","commit":{"message":"Add coverage hooks\n\nMore details","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"}}}]
JSON
    ;;
  repos/tinyhumansai/openhuman/issues\?*)
    cat <<'JSON'
[{"number":7,"title":"Memory source reader gap","body":"Needs fixture coverage","state":"open","user":{"login":"ada"},"labels":[{"name":"coverage"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":null},{"number":99,"title":"PR-shaped issue","body":"","state":"open","user":{"login":"bot"},"labels":[],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":{}}]
JSON
    ;;
  repos/tinyhumansai/openhuman/pulls\?*)
    cat <<'JSON'
[{"number":9,"title":"Raw coverage PR","body":"PR body","state":"open","user":{"login":"grace"},"labels":[{"name":"tests"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T12:00:00Z","merged_at":null,"comments":1}]
JSON
    ;;
  repos/tinyhumansai/openhuman/commits/abc123)
    cat <<'JSON'
{"sha":"abc123","commit":{"message":"Add coverage hooks\n\nMore details","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"}}}
JSON
    ;;
  repos/tinyhumansai/openhuman/issues/7)
    cat <<'JSON'
{"number":7,"title":"Memory source reader gap","body":"Needs fixture coverage","state":"open","user":{"login":"ada"},"labels":[{"name":"coverage"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":null}
JSON
    ;;
  repos/tinyhumansai/openhuman/pulls/9)
    cat <<'JSON'
{"number":9,"title":"Raw coverage PR","body":"PR body","state":"open","user":{"login":"grace"},"labels":[{"name":"tests"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T12:00:00Z","merged_at":null,"comments":1}
JSON
    ;;
  repos/tinyhumansai/openhuman/issues/7/comments\?*|repos/tinyhumansai/openhuman/issues/9/comments\?*)
    cat <<'JSON'
[{"user":{"login":"reviewer"},"body":"Looks deterministic","created_at":"2026-05-28T13:00:00Z"}]
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");
    }
}

fn write_fake_git(path: &PathBuf) {
    let script = r#"#!/usr/bin/env bash
# Fake git that fails on clone/fetch so the GitHub reader falls through to the
# gh CLI API path (which is intercepted by write_fake_gh).
case "${1:-}" in
  clone|fetch)
    echo "fatal: repository not found" >&2
    exit 128
    ;;
  *)
    exec /usr/bin/git "$@"
    ;;
esac
"#;
    std::fs::write(path, script).expect("write fake git");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(path)
            .expect("fake git metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(path, perms).expect("chmod fake git");
    }
}
