//! End-to-end coverage for the orchestrator memory-tree retrieval tool
//! wrappers (issue #710 wiring).
//!
//! Goal: prove the `MemoryTree*Tool` instances actually drive the typed
//! retrieval functions against a real ingested workspace and emit JSON the
//! orchestrator LLM can parse + cite from.
//!
//! Why a tool-direct test (and not a full `agent_chat` round-trip):
//! `agent_chat` requires a reachable provider (no provider connection
//! available in unit-test context). The bus-level `mock_agent_run_turn`
//! stub replaces the agent loop wholesale, so it can't observe a tool
//! dispatch happening *inside* the loop. Calling each tool's `execute()`
//! with the same JSON shape the LLM would emit exercises the full
//! deserialise → typed retrieval → serialise pipeline that the orchestrator
//! relies on, and asserts the data round-trips correctly.
//!
//! The orchestrator agent.toml entry registering these tool names is
//! covered by [`orchestrator_lists_memory_tree_tools`] — that catches a
//! regression where the tool wrapper exists but the orchestrator can't see
//! it.

use chrono::{TimeZone, Utc};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::tree::canonicalize::email::{EmailMessage, EmailThread};
use openhuman_core::openhuman::memory::tree::ingest::ingest_email;
use openhuman_core::openhuman::memory::tree::jobs::drain_until_idle;
use openhuman_core::openhuman::tools::{
    MemoryTreeFetchLeavesTool, MemoryTreeQueryTopicTool, MemoryTreeSearchEntitiesTool, Tool,
};
use serde_json::{json, Value};
use tempfile::TempDir;

/// Build a Config rooted at `tmp/workspace`. The nested `workspace` dir
/// matches what `resolve_config_dir_for_workspace` would derive when
/// `OPENHUMAN_WORKSPACE` points at `tmp` — so the same workspace_dir is
/// used both by the explicit ingest path and by `load_config_with_timeout`
/// inside the tool wrappers.
fn test_config() -> (TempDir, Config) {
    let tmp = TempDir::new().unwrap();
    let workspace_dir = tmp.path().join("workspace");
    std::fs::create_dir_all(&workspace_dir).expect("create workspace dir");
    let mut cfg = Config {
        workspace_dir: workspace_dir.clone(),
        ..Config::default()
    };
    // Inert embedder — keeps the test deterministic and avoids any real
    // Ollama call. Mirrors `retrieval/integration_test.rs`.
    cfg.memory_tree.embedding_endpoint = None;
    cfg.memory_tree.embedding_model = None;
    cfg.memory_tree.embedding_strict = false;
    (tmp, cfg)
}

fn alice_phoenix_thread() -> EmailThread {
    EmailThread {
        provider: "gmail".into(),
        thread_subject: "Phoenix migration plan".into(),
        messages: vec![
            EmailMessage {
                from: "alice@example.com".into(),
                to: vec!["bob@example.com".into()],
                cc: vec![],
                subject: "Phoenix migration plan".into(),
                sent_at: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
                body: "Hey Bob, the phoenix migration runbook is ready for review. \
                       I'm coordinating with the infra team and we land Friday."
                    .into(),
                source_ref: Some("<phoenix-1@example.com>".into()),
            },
            EmailMessage {
                from: "bob@example.com".into(),
                to: vec!["alice@example.com".into()],
                cc: vec![],
                subject: "Re: Phoenix migration plan".into(),
                sent_at: Utc.timestamp_millis_opt(1_700_000_060_000).unwrap(),
                body: "Confirmed — I'll review the phoenix runbook tonight.".into(),
                source_ref: Some("<phoenix-2@example.com>".into()),
            },
        ],
    }
}

/// The orchestrator definition must list the consolidated `memory_tree` tool
/// so the bus filter exposes it to the LLM. A wired-up wrapper that's
/// invisible to the orchestrator is dead code.
///
/// NOTE: #1141 consolidated the 6 individual `memory_tree_*` tools
/// (`memory_tree_search_entities`, `memory_tree_query_topic`, etc.) into a
/// single `memory_tree` tool with a `mode` dispatch parameter. The orchestrator
/// TOML was updated accordingly.
#[test]
fn orchestrator_lists_memory_tree_tools() {
    let toml = include_str!("../src/openhuman/agent/agents/orchestrator/agent.toml");
    // Exact entry match — substring match would also hit comments or prefixed names.
    let has_memory_tree_entry = toml
        .lines()
        .map(str::trim)
        .any(|line| line == "\"memory_tree\"" || line == "\"memory_tree\",");
    assert!(
        has_memory_tree_entry,
        "orchestrator agent.toml must list 'memory_tree' as a named tool entry"
    );
    // Verify the old individual tool names are gone — they were removed in #1141
    // when all 6 were consolidated into the single `memory_tree` dispatcher.
    for old_name in [
        "memory_tree_search_entities",
        "memory_tree_query_topic",
        "memory_tree_query_source",
        "memory_tree_query_global",
        "memory_tree_drill_down",
        "memory_tree_fetch_leaves",
    ] {
        let entry = format!("\"{old_name}\"");
        let entry_comma = format!("\"{old_name}\",");
        let old_tool_present = toml
            .lines()
            .map(str::trim)
            .any(|line| line == entry || line == entry_comma);
        assert!(
            !old_tool_present,
            "orchestrator agent.toml must NOT list '{old_name}' — removed in #1141 (use 'memory_tree' with mode= dispatch)"
        );
    }
}

#[tokio::test]
async fn orchestrator_query_topic_tool_returns_alice_phoenix_hits() {
    let (tmp, cfg) = test_config();

    // ── Ingest the email thread + drain async extract jobs so the entity
    //    index is fully populated before retrieval.
    ingest_email(
        &cfg,
        "gmail:thread-phoenix-1",
        "alice",
        vec![],
        alice_phoenix_thread(),
    )
    .await
    .expect("ingest_email should succeed");
    drain_until_idle(&cfg)
        .await
        .expect("job queue should drain cleanly");

    // ── Set workspace dir so config_rpc::load_config_with_timeout()
    //    inside the tool resolves to the same workspace we just ingested
    //    into. The tool wrappers always go through that loader (mirrors
    //    the production RPC handlers in retrieval/schemas.rs).
    struct EnvGuard {
        key: &'static str,
        prev: Option<std::ffi::OsString>,
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            // SAFETY: see `EnvGuard::set` below — this integration test
            // binary owns the env var for its lifetime.
            unsafe {
                match self.prev.take() {
                    Some(v) => std::env::set_var(self.key, v),
                    None => std::env::remove_var(self.key),
                }
            }
        }
    }
    impl EnvGuard {
        fn set(key: &'static str, val: &std::ffi::OsStr) -> Self {
            let prev = std::env::var_os(key);
            // SAFETY: `cargo test` defaults to running each integration
            // test bin in its own process; nothing else in this bin
            // mutates `OPENHUMAN_WORKSPACE`. The guard restores the
            // previous value on drop.
            unsafe { std::env::set_var(key, val) };
            Self { key, prev }
        }
    }
    // Pointing OPENHUMAN_WORKSPACE at `tmp` (not `tmp/workspace`) makes
    // `resolve_config_dir_for_workspace` derive `tmp/workspace` as the
    // resolved workspace_dir — matching what we already passed into
    // `ingest_email` via `cfg.workspace_dir`.
    let _ws_guard = EnvGuard::set("OPENHUMAN_WORKSPACE", tmp.path().as_os_str());

    // ── 1. search_entities resolves "alice" → email:alice@example.com.
    //    Mirrors the orchestrator prompt's "ALWAYS call this first when
    //    the user mentions someone by name" flow.
    let search = MemoryTreeSearchEntitiesTool;
    let search_args = json!({"query": "alice"});
    let search_res = search
        .execute(search_args)
        .await
        .expect("search_entities should not error");
    assert!(
        !search_res.is_error,
        "search_entities returned an error result: {}",
        search_res.output()
    );
    let search_json: Value =
        serde_json::from_str(&search_res.output()).expect("search output must be valid JSON");
    let matches = search_json
        .as_array()
        .expect("search_entities returns an array of EntityMatch");
    let alice = matches
        .iter()
        .find(|m| m.get("canonical_id").and_then(|v| v.as_str()) == Some("email:alice@example.com"))
        .unwrap_or_else(|| panic!("search_entities did not return alice; got: {search_json:?}"));
    assert!(
        alice
            .get("mention_count")
            .and_then(|v| v.as_u64())
            .unwrap_or(0)
            >= 1,
        "alice should have at least one mention"
    );

    // ── 2. query_topic on alice's canonical id returns at least one hit
    //    referencing both her email and the phoenix migration content.
    let topic_tool = MemoryTreeQueryTopicTool;
    let topic_args = json!({"entity_id": "email:alice@example.com"});
    let topic_res = topic_tool
        .execute(topic_args)
        .await
        .expect("query_topic should not error");
    assert!(
        !topic_res.is_error,
        "query_topic returned an error result: {}",
        topic_res.output()
    );
    let topic_json: Value =
        serde_json::from_str(&topic_res.output()).expect("topic output must be valid JSON");
    let hits = topic_json
        .get("hits")
        .and_then(|v| v.as_array())
        .expect("query_topic must include `hits` array");
    assert!(
        !hits.is_empty(),
        "query_topic returned zero hits — expected at least one for alice"
    );
    // Returning ANY hit at all from `query_topic("email:alice@example.com")`
    // proves the entity index resolved the canonical id and hydrated nodes
    // back. The leaf-level `entities` field on a chunk hit isn't populated
    // synchronously by ingest — entity extraction lives in a separate async
    // job stage that may not have populated leaf rows. Instead we assert on
    // the hydrated content + source_ref so we still catch a regression where
    // the chunk lookup returns garbage.
    let any_phoenix = hits.iter().any(|h| {
        h.get("content")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_lowercase()
            .contains("phoenix")
    });
    assert!(
        any_phoenix,
        "expected at least one query_topic hit with phoenix content; got: {topic_json:#}"
    );
    let any_source_ref = hits
        .iter()
        .any(|h| h.get("source_ref").and_then(|v| v.as_str()).is_some());
    assert!(
        any_source_ref,
        "expected at least one hit to carry a `source_ref` for citation; got: {topic_json:#}"
    );

    // ── 3. fetch_leaves hydrates a leaf chunk — proves the citation path
    //    (LLM picks an id from a query_* hit, calls fetch_leaves to get
    //    the verbatim content + source_ref).
    let leaf_id = hits
        .iter()
        .find_map(|h| {
            if h.get("node_kind").and_then(|v| v.as_str()) == Some("leaf") {
                h.get("node_id")
                    .and_then(|v| v.as_str())
                    .map(str::to_string)
            } else {
                None
            }
        })
        .expect("alice's topic hits should include at least one leaf");
    let fetch_tool = MemoryTreeFetchLeavesTool;
    let fetch_args = json!({"chunk_ids": [leaf_id.clone()]});
    let fetch_res = fetch_tool
        .execute(fetch_args)
        .await
        .expect("fetch_leaves should not error");
    assert!(
        !fetch_res.is_error,
        "fetch_leaves returned an error result: {}",
        fetch_res.output()
    );
    let fetched: Value =
        serde_json::from_str(&fetch_res.output()).expect("fetch output must be valid JSON");
    let fetched_arr = fetched.as_array().expect("fetch_leaves returns array");
    assert_eq!(
        fetched_arr.len(),
        1,
        "fetch_leaves should hydrate exactly the requested chunk"
    );
    let content = fetched_arr[0]
        .get("content")
        .and_then(|v| v.as_str())
        .expect("fetched leaf must carry content");
    assert!(
        !content.is_empty(),
        "fetched leaf content must not be empty"
    );
}
