//! `explore` tool: fan out read-only explorer subagents over the codebase in
//! parallel and return their combined findings.
//!
//! Each child is a full [`Agent`](crate::agent::Agent) built from the
//! parent's config resolved to the `explore` role model, with the ReadOnly
//! tool profile: only `read_file` + `run_shell_command`, any
//! approval-requiring command auto-denied (never prompts), no MCP, and no
//! `explore` tool (no recursive spawning). Children run concurrently under a
//! `JoinSet`, bounded by iteration count and a wall-clock timeout.

use crate::agent::AgentEvent;
use crate::config::{ApprovalPolicy, Config, Mode, ToolProfile};
use serde_json::Value;
use std::time::Duration;
use futures_util::{stream::FuturesUnordered, StreamExt};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

pub const DEFAULT_MAX_AGENTS: u64 = 4;
pub const MAX_AGENTS_CAP: u64 = 6;
/// Children get a short leash: they explore and report, nothing more.
pub const CHILD_MAX_ITERATIONS: u32 = 12;
/// Per-child wall-clock budget (~4 min).
pub const CHILD_TIMEOUT_SECS: u64 = 240;

/// Build the restricted config for an explorer child from the parent config:
/// `explore` role model, ReadOnly tool profile, Agent mode (the profile — not
/// the mode — is what restricts it), auto approvals (which the ReadOnly
/// profile turns into auto-DENY for anything risky), short iteration budget,
/// and no MCP servers.
pub fn child_config(parent: &Config) -> Config {
    let mut cfg = parent.role_config("explore");
    cfg.tool_profile = ToolProfile::ReadOnly;
    cfg.mode = Mode::Agent;
    cfg.approval = ApprovalPolicy::Auto;
    cfg.max_iterations = CHILD_MAX_ITERATIONS;
    cfg.mcp_servers.clear();
    cfg
}

/// Extract task prompts from the tool arguments.
pub fn parse_tasks(args: &Value) -> Result<Vec<String>, String> {
    let tasks: Vec<String> = args["tasks"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|t| t["prompt"].as_str())
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default();
    if tasks.is_empty() {
        return Err("Error: 'tasks' must be a non-empty array of {prompt: string} objects.".into());
    }
    Ok(tasks)
}

fn short_label(task: &str) -> String {
    let one_line = task.lines().next().unwrap_or("");
    let mut s: String = one_line.chars().take(80).collect();
    if one_line.chars().count() > 80 {
        s.push('…');
    }
    s
}

/// Run the explore fan-out: spawn one child agent per task (capped by
/// `max_agents`), stream `SubagentStatus` lifecycle events to the parent
/// channel, and return the joined "## Explorer N: …" report.
pub async fn run_explore(
    parent_cfg: &Config,
    args: &Value,
    tx: &mpsc::UnboundedSender<AgentEvent>,
    cancel: &CancellationToken,
) -> String {
    let tasks = match parse_tasks(args) {
        Ok(t) => t,
        Err(e) => return e,
    };
    let max_agents = args["max_agents"]
        .as_u64()
        .unwrap_or(DEFAULT_MAX_AGENTS)
        .clamp(1, MAX_AGENTS_CAP) as usize;
    let tasks: Vec<String> = tasks.into_iter().take(max_agents).collect();
    let base = child_config(parent_cfg);

    let mut futures = FuturesUnordered::new();
    for (i, task) in tasks.iter().enumerate() {
        let cfg = base.clone();
        let tx = tx.clone();
        let task = task.clone();
        let cancel = cancel.clone();
        futures.push(async move {
            let label = short_label(&task);
            let _ = tx.send(AgentEvent::SubagentStatus {
                id: i,
                task: label.clone(),
                state: "started".into(),
            });
            // The child gets its own event channel. Its deltas/tool events are
            // not forwarded — only lifecycle reaches the parent stream. All
            // agent sends ignore a closed receiver, so dropping it is safe.
            let (child_tx, child_rx) = mpsc::unbounded_channel();
            drop(child_rx);
            let mut child = crate::agent::Agent::new(cfg);
            let prompt = format!(
                "You are a read-only explorer subagent. Investigate the task below in this repository \
                 using read_file and read-only shell commands (ls, grep, find, cat, git log...). \
                 You cannot edit files or run state-changing commands — any such attempt is denied. \
                 Finish with a concise findings report: relevant file paths, key symbols, and direct answers.\n\nTask: {task}"
            );
            let run = child.run_turn(prompt, &child_tx, &cancel);
            let (state, report) = match tokio::time::timeout(
                Duration::from_secs(CHILD_TIMEOUT_SECS),
                run,
            )
            .await
            {
                Ok(()) => {
                    // Final report = last assistant message without tool calls.
                    let text = child
                        .history
                        .iter()
                        .rev()
                        .find(|m| m.role == "assistant" && m.tool_calls.is_empty())
                        .map(|m| m.content.clone())
                        .unwrap_or_default();
                    if text.trim().is_empty() {
                        ("failed", "(explorer produced no final report)".to_string())
                    } else {
                        ("finished", text)
                    }
                }
                Err(_) => (
                    "failed",
                    format!("(explorer timed out after {CHILD_TIMEOUT_SECS}s)"),
                ),
            };
            let _ = tx.send(AgentEvent::SubagentStatus {
                id: i,
                task: label,
                state: state.into(),
            });
            (i, task, report)
        });
    }

    let mut results: Vec<(usize, String, String)> = Vec::new();
    while let Some(r) = futures.next().await {
        results.push(r);
    }
    results.sort_by_key(|r| r.0);
    results
        .iter()
        .map(|(i, task, report)| {
            let n = if *i == usize::MAX { "?".to_string() } else { (i + 1).to_string() };
            format!("## Explorer {n}: {}\n{report}", short_label(task))
        })
        .collect::<Vec<_>>()
        .join("\n\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn child_config_is_restricted() {
        let parent = Config::default();
        let cfg = child_config(&parent);
        assert_eq!(cfg.tool_profile, ToolProfile::ReadOnly);
        assert_eq!(cfg.mode, Mode::Agent);
        assert_eq!(cfg.approval, ApprovalPolicy::Auto);
        assert_eq!(cfg.max_iterations, CHILD_MAX_ITERATIONS);
        assert!(cfg.mcp_servers.is_empty());
        // Role-resolved cheap fan-out model, parent untouched.
        assert_eq!(cfg.model, parent.role_model("explore"));
        assert_eq!(parent.model, "tencent/hy3:free");
    }

    #[test]
    fn child_toolset_excludes_write_edit_web_and_explore() {
        let parent = Config::default();
        let cfg = child_config(&parent);
        let tools = crate::tools::tools_for(cfg.tool_profile, cfg.mode, &[]);
        let names = crate::tools::tool_names(&tools);
        assert_eq!(names, vec!["read_file", "run_shell_command"]);
        for banned in ["write_file", "edit_file", "web_search", "read_url", "explore", "analyze_image", "manage_background_task"] {
            assert!(!names.contains(&banned.to_string()), "child must not expose {banned}");
        }
    }

    #[test]
    fn parse_tasks_extracts_prompts_and_rejects_empty() {
        let args = json!({ "tasks": [ {"prompt": "map the auth flow"}, {"prompt": "find the config loader"} ] });
        assert_eq!(
            parse_tasks(&args).unwrap(),
            vec!["map the auth flow".to_string(), "find the config loader".to_string()]
        );
        assert!(parse_tasks(&json!({})).is_err());
        assert!(parse_tasks(&json!({ "tasks": [] })).is_err());
        assert!(parse_tasks(&json!({ "tasks": [{"prompt": "  "}] })).is_err());
    }
}
