//! Model-facing todo / task tools.
//!
//! Three surfaces share one engine ([`TodoRuntime`]):
//!
//! - [`make_update_plan_tool`] — Codex-compatible OpenAI `update_plan`.
//! - [`make_todo_list_tool`] — Kimi Code-compatible whole-list `TodoList`.
//! - [`make_task_create_tool`] / [`make_task_update_tool`] /
//!   [`make_task_get_tool`] / [`make_task_list_tool`] — Claude task tools.

use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write;
use std::str::FromStr;
use std::sync::Arc;

use fabro_llm::types::ToolDefinition;
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps};
use serde_json::Value;
use strum::{EnumString, IntoStaticStr};

use crate::todo_runtime::TodoRuntime;
use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource};

/// Compute a session-scoped todo-list ID. Returns an error the model can see
/// when a tool is invoked without an active session.
fn session_todo_scope(
    ctx: &ToolContext,
    kind: TodoListKind,
    tool_name: &str,
) -> Result<String, String> {
    ctx.session_id
        .as_ref()
        .map(|session_id| kind.list_id(session_id))
        .ok_or_else(|| format!("{tool_name} requires an active session"))
}

/// Compute the Anthropic task scope
/// (`anthropic_tasks:<root_session_id>`). Falls back to `session_id` when
/// the root is not bound; errors if neither is set.
fn anthropic_task_scope(ctx: &ToolContext) -> Result<String, String> {
    ctx.root_session_id
        .as_ref()
        .or(ctx.session_id.as_ref())
        .map(|sid| TodoListKind::AnthropicTasks.list_id(sid))
        .ok_or_else(|| "task tools require an active session".to_string())
}

/// Parse a wire status string into a [`TodoStatus`], optionally rejecting
/// `"deleted"` (OpenAI's `update_plan` does not accept deletions).
fn parse_status(value: &str, allow_deleted: bool) -> Result<TodoStatus, String> {
    let status = TodoStatus::from_str(value).map_err(|_| {
        if allow_deleted {
            format!("Invalid status `{value}` (expected pending|in_progress|completed|deleted)")
        } else {
            format!("Invalid status `{value}` (expected pending|in_progress|completed)")
        }
    })?;
    if !allow_deleted && status == TodoStatus::Deleted {
        return Err(format!(
            "Invalid status `{value}` (expected pending|in_progress|completed)"
        ));
    }
    Ok(status)
}

const TASK_CREATE_DESCRIPTION: &str = "Create pending tasks in the current session. \
Use concise subjects, descriptions, optional activeForm text, and metadata. Check \
TaskList first to avoid duplicate tasks.";

const TASK_UPDATE_DESCRIPTION: &str = "Update an existing task's status, text, owner, \
metadata, or dependencies. Valid statuses are pending, in_progress, completed, and \
deleted. After completing a task, call TaskList to find newly unblocked work.";

const TASK_LIST_DESCRIPTION: &str = "List tasks for the current session, including \
status, owner, and blocking dependencies. Use TaskGet with a taskId for full \
description and dependency details.";

const TASK_GET_DESCRIPTION: &str = "Get one task by taskId, including subject, status, \
description, owner, blockedBy, and blocks.";

/// Deterministic todo id derived from `<list_id>::<text>`. Whole-list tools
/// identify an item by its exact text, so unchanged entries preserve identity.
fn todo_text_id(list_id: &str, text: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(list_id.as_bytes());
    hasher.update(b"\x00");
    hasher.update(text.as_bytes());
    let digest = hasher.finalize();
    let mut out = String::with_capacity(16);
    for byte in &digest[..8] {
        let _ = write!(out, "{byte:02x}");
    }
    out
}

struct ReplacementTodo {
    id:      String,
    subject: String,
    status:  TodoStatus,
}

fn reconcile_replacement_list(
    runtime: &TodoRuntime,
    ctx: &ToolContext,
    kind: TodoListKind,
    list_id: &str,
    incoming: &[ReplacementTodo],
) {
    let previous = runtime
        .snapshot(list_id)
        .map(|list| list.items)
        .unwrap_or_default();
    let previous_by_id: HashMap<&str, &TodoProjection> = previous
        .iter()
        .map(|todo| (todo.id.as_str(), todo))
        .collect();
    let incoming_ids: HashSet<&str> = incoming.iter().map(|todo| todo.id.as_str()).collect();

    for todo in &previous {
        if !incoming_ids.contains(todo.id.as_str()) {
            runtime.delete(ctx, kind, list_id.to_string(), todo.id.clone());
        }
    }

    for (index, todo) in incoming.iter().enumerate() {
        let order = u32::try_from(index).unwrap_or(u32::MAX);
        match previous_by_id.get(todo.id.as_str()) {
            Some(previous)
                if previous.status == todo.status
                    && previous.order == order
                    && previous.subject == todo.subject => {}
            Some(_) => {
                runtime.update(ctx, TodoUpdatedProps {
                    status: Some(todo.status),
                    order: Some(order),
                    subject: Some(todo.subject.clone()),
                    ..TodoUpdatedProps::new(list_id, kind, &todo.id)
                });
            }
            None => {
                let mut projection =
                    TodoProjection::new(todo.id.clone(), order, todo.subject.clone());
                projection.status = todo.status;
                runtime.create(ctx, kind, list_id.to_string(), projection);
            }
        }
    }
}

/// OpenAI `update_plan` tool. See plan summary for semantics.
#[must_use]
pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "update_plan".into(),
            description: "Update the multi-step plan for the current task. Submit the entire \
                          plan; existing steps are reconciled by exact step text."
                .into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {
                    "explanation": {
                        "type": "string",
                        "description": "Optional natural-language note about why the plan changed"
                    },
                    "plan": {
                        "type": "array",
                        "description": "Ordered list of plan steps, each with a status",
                        "items": {
                            "type": "object",
                            "properties": {
                                "step": {"type": "string"},
                                "status": {
                                    "type": "string",
                                    "enum": ["pending", "in_progress", "completed"]
                                }
                            },
                            "required": ["step", "status"]
                        }
                    }
                },
                "required": ["plan"]
            }),
        },
        executor:   Arc::new(move |args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = session_todo_scope(&ctx, TodoListKind::OpenAiPlan, "update_plan")?;
                let plan = args
                    .get("plan")
                    .and_then(Value::as_array)
                    .ok_or_else(|| "Missing required parameter: plan".to_string())?;

                // Parse incoming steps, precompute ids, and enforce step-text uniqueness.
                let mut incoming = Vec::with_capacity(plan.len());
                let mut seen_steps: HashSet<&str> = HashSet::with_capacity(plan.len());
                for (index, entry) in plan.iter().enumerate() {
                    let step = entry
                        .get("step")
                        .and_then(Value::as_str)
                        .ok_or_else(|| format!("plan[{index}] is missing `step`"))?;
                    let status = entry
                        .get("status")
                        .and_then(Value::as_str)
                        .ok_or_else(|| format!("plan[{index}] is missing `status`"))?;
                    let status = parse_status(status, false)?;
                    if !seen_steps.insert(step) {
                        return Err(format!(
                            "Duplicate plan step `{step}` — step text must be unique"
                        ));
                    }
                    incoming.push(ReplacementTodo {
                        id: todo_text_id(&list_id, step),
                        subject: step.to_string(),
                        status,
                    });
                }

                reconcile_replacement_list(
                    &runtime,
                    &ctx,
                    TodoListKind::OpenAiPlan,
                    &list_id,
                    &incoming,
                );

                Ok("Plan updated".to_string())
            })
        }),
        source:     ToolSource::Native,
    }
}

#[derive(Clone, Copy, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
enum KimiTodoStatus {
    Pending,
    InProgress,
    #[strum(to_string = "done")]
    Done,
}

impl From<KimiTodoStatus> for TodoStatus {
    fn from(status: KimiTodoStatus) -> Self {
        match status {
            KimiTodoStatus::Pending => Self::Pending,
            KimiTodoStatus::InProgress => Self::InProgress,
            KimiTodoStatus::Done => Self::Completed,
        }
    }
}

impl From<TodoStatus> for KimiTodoStatus {
    fn from(status: TodoStatus) -> Self {
        match status {
            TodoStatus::Pending => Self::Pending,
            TodoStatus::InProgress => Self::InProgress,
            TodoStatus::Completed | TodoStatus::Deleted => Self::Done,
        }
    }
}

/// Kimi Code spells the terminal status `done`; internally it is
/// [`TodoStatus::Completed`].
fn parse_kimi_status(value: &str) -> Result<TodoStatus, String> {
    value
        .parse::<KimiTodoStatus>()
        .map(TodoStatus::from)
        .map_err(|_| format!("Invalid status `{value}` (expected pending|in_progress|done)"))
}

fn kimi_status_name(status: TodoStatus) -> &'static str {
    KimiTodoStatus::from(status).into()
}

fn render_kimi_todos<'a>(items: impl IntoIterator<Item = (TodoStatus, &'a str)>) -> String {
    let mut items = items.into_iter().peekable();
    if items.peek().is_none() {
        return "The todo list is empty.".to_string();
    }
    let mut out = String::new();
    for (status, subject) in items {
        let _ = writeln!(out, "[{}] {subject}", kimi_status_name(status));
    }
    out.truncate(out.trim_end().len());
    out
}

/// Kimi Code-compatible `TodoList`.
///
/// A single tool serves reads and writes, matching the surface Kimi models are
/// trained against: omit `todos` to read, pass `[]` to clear, pass a list to
/// replace the whole thing. Items carry only `title` and `status`, and the
/// terminal status is spelled `done`.
///
/// Reconciliation mirrors `update_plan` — items are identified by their text,
/// so a re-submitted list preserves identity for unchanged entries — and the
/// same [`TodoRuntime`] backs it, so projections and events are unchanged.
pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "TodoList".into(),
            description: "Maintain a structured TODO list for the current task. Use it \
                          proactively for multi-step work. Pass `todos` to replace the entire \
                          list, omit `todos` to read the current list without changing it, and \
                          pass an empty array to clear it. Keep exactly one item `in_progress` \
                          while work is underway, and mark an item `done` as soon as it is \
                          finished rather than batching completions at the end."
                .into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {
                    "todos": {
                        "type": "array",
                        "description": "The updated todo list. Omit to read the current list \
            without making changes. Pass an empty array to clear the list.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "title": {
                                    "type": "string",
                                    "description": "Short, actionable title for the todo."
                                },
                                "status": {
                                    "type": "string",
                                    "enum": ["pending", "in_progress", "done"],
                                    "description": "Current status of the todo."
                                }
                            },
                            "required": ["title", "status"]
                        }
                    }
                }
            }),
        },
        executor:   Arc::new(move |args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = session_todo_scope(&ctx, TodoListKind::KimiTodos, "TodoList")?;

                // Read mode: `todos` omitted entirely.
                let Some(todos) = args.get("todos") else {
                    let items = runtime
                        .snapshot(&list_id)
                        .map(|l| l.items)
                        .unwrap_or_default();
                    return Ok(render_kimi_todos(
                        items
                            .iter()
                            .map(|todo| (todo.status, todo.subject.as_str())),
                    ));
                };
                let todos = todos
                    .as_array()
                    .ok_or_else(|| "`todos` must be an array".to_string())?;

                let mut incoming = Vec::with_capacity(todos.len());
                let mut seen: HashSet<&str> = HashSet::with_capacity(todos.len());
                for (index, entry) in todos.iter().enumerate() {
                    let title = entry
                        .get("title")
                        .and_then(Value::as_str)
                        .ok_or_else(|| format!("todos[{index}] is missing `title`"))?;
                    let status = entry
                        .get("status")
                        .and_then(Value::as_str)
                        .ok_or_else(|| format!("todos[{index}] is missing `status`"))?;
                    let status = parse_kimi_status(status)?;
                    if !seen.insert(title) {
                        return Err(format!("Duplicate todo `{title}` — titles must be unique"));
                    }
                    incoming.push(ReplacementTodo {
                        id: todo_text_id(&list_id, title),
                        subject: title.to_string(),
                        status,
                    });
                }

                reconcile_replacement_list(
                    &runtime,
                    &ctx,
                    TodoListKind::KimiTodos,
                    &list_id,
                    &incoming,
                );
                Ok(render_kimi_todos(
                    incoming
                        .iter()
                        .map(|todo| (todo.status, todo.subject.as_str())),
                ))
            })
        }),
        source:     ToolSource::Native,
    }
}

fn optional_string(args: &Value, key: &str) -> Option<String> {
    args.get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
}

fn optional_string_vec(args: &Value, key: &str) -> Option<Vec<String>> {
    args.get(key).and_then(Value::as_array).map(|values| {
        values
            .iter()
            .filter_map(|v| v.as_str().map(ToString::to_string))
            .collect()
    })
}

fn metadata_map(args: &Value) -> BTreeMap<String, Value> {
    args.get("metadata")
        .and_then(Value::as_object)
        .map(|map| {
            map.iter()
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect::<BTreeMap<_, _>>()
        })
        .unwrap_or_default()
}

fn append_task_refs(out: &mut String, label: &str, task_ids: &[String]) {
    if task_ids.is_empty() {
        return;
    }
    let _ = write!(out, "\n{label}: ");
    for (index, task_id) in task_ids.iter().enumerate() {
        if index > 0 {
            out.push_str(", ");
        }
        let _ = write!(out, "#{task_id}");
    }
}

fn format_task_details(todo: &TodoProjection) -> String {
    let mut out = format!(
        "Task #{}: {}\nStatus: {}\nDescription: {}",
        todo.id, todo.subject, todo.status, todo.description
    );
    if let Some(owner) = todo.owner.as_ref() {
        let _ = write!(out, "\nOwner: {owner}");
    }
    append_task_refs(&mut out, "Blocked by", &todo.blocked_by);
    append_task_refs(&mut out, "Blocks", &todo.blocks);
    out
}

#[must_use]
pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "TaskCreate".into(),
            description: TASK_CREATE_DESCRIPTION.into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {
                    "subject":     {"type": "string"},
                    "description": {"type": "string"},
                    "activeForm":  {"type": "string"},
                    "metadata":    {"type": "object", "additionalProperties": true}
                },
                "required": ["subject", "description"]
            }),
        },
        executor:   Arc::new(move |args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = anthropic_task_scope(&ctx)?;
                let subject = args
                    .get("subject")
                    .and_then(Value::as_str)
                    .ok_or_else(|| "Missing required parameter: subject".to_string())?
                    .to_string();
                let description = args
                    .get("description")
                    .and_then(Value::as_str)
                    .ok_or_else(|| "Missing required parameter: description".to_string())?
                    .to_string();
                let task_id = runtime.next_task_id(&list_id);
                let id_string = task_id.to_string();
                let order = u32::try_from(task_id.saturating_sub(1)).unwrap_or(u32::MAX);

                let mut projection = TodoProjection::new(id_string, order, subject.clone());
                projection.description = description;
                projection.active_form = optional_string(&args, "activeForm");
                projection.metadata = metadata_map(&args);

                runtime.create(&ctx, TodoListKind::AnthropicTasks, list_id, projection);

                Ok(format!("Task #{task_id} created successfully: {subject}"))
            })
        }),
        source:     ToolSource::Native,
    }
}

#[must_use]
pub fn make_task_update_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "TaskUpdate".into(),
            description: TASK_UPDATE_DESCRIPTION.into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {
                    "taskId":       {"type": "string"},
                    "subject":      {"type": "string"},
                    "description":  {"type": "string"},
                    "activeForm":   {"type": "string"},
                    "status":       {
                        "type": "string",
                        "enum": ["pending", "in_progress", "completed", "deleted"]
                    },
                    "owner":        {"type": "string"},
                    "addBlocks":    {"type": "array", "items": {"type": "string"}},
                    "addBlockedBy": {"type": "array", "items": {"type": "string"}},
                    "metadata":     {"type": "object", "additionalProperties": true}
                },
                "required": ["taskId"]
            }),
        },
        executor:   Arc::new(move |args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = anthropic_task_scope(&ctx)?;
                let task_id = args
                    .get("taskId")
                    .and_then(Value::as_str)
                    .ok_or_else(|| "Missing required parameter: taskId".to_string())?
                    .to_string();

                let status = args
                    .get("status")
                    .and_then(Value::as_str)
                    .map(|s| parse_status(s, true))
                    .transpose()?;

                let props = TodoUpdatedProps {
                    status,
                    subject: optional_string(&args, "subject"),
                    description: optional_string(&args, "description"),
                    active_form: args
                        .get("activeForm")
                        .map(|value| value.as_str().map(ToString::to_string)),
                    owner: args
                        .get("owner")
                        .map(|value| value.as_str().map(ToString::to_string)),
                    add_blocks: optional_string_vec(&args, "addBlocks"),
                    add_blocked_by: optional_string_vec(&args, "addBlockedBy"),
                    metadata_patch: metadata_map(&args),
                    ..TodoUpdatedProps::new(&list_id, TodoListKind::AnthropicTasks, &task_id)
                };

                if runtime.update(&ctx, props) {
                    Ok(format!("Task #{task_id} updated"))
                } else {
                    // Anthropic spec: missing task returns a non-error result.
                    Ok("Task not found".to_string())
                }
            })
        }),
        source:     ToolSource::Native,
    }
}

#[must_use]
pub fn make_task_get_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "TaskGet".into(),
            description: TASK_GET_DESCRIPTION.into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {
                    "taskId": {"type": "string"}
                },
                "required": ["taskId"]
            }),
        },
        executor:   Arc::new(move |args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = anthropic_task_scope(&ctx)?;
                let task_id = args
                    .get("taskId")
                    .and_then(Value::as_str)
                    .ok_or_else(|| "Missing required parameter: taskId".to_string())?;

                let Some(snapshot) = runtime.snapshot(&list_id) else {
                    return Ok("Task not found".to_string());
                };
                let Some(todo) = snapshot.get(task_id) else {
                    return Ok("Task not found".to_string());
                };

                Ok(format_task_details(todo))
            })
        }),
        source:     ToolSource::Native,
    }
}

#[must_use]
pub fn make_task_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
    RegisteredTool {
        definition: ToolDefinition {
            name:        "TaskList".into(),
            description: TASK_LIST_DESCRIPTION.into(),
            parameters:  serde_json::json!({
                "type": "object",
                "properties": {},
                "additionalProperties": false
            }),
        },
        executor:   Arc::new(move |_args, ctx| {
            let runtime = runtime.clone();
            Box::pin(async move {
                let list_id = anthropic_task_scope(&ctx)?;
                let snapshot = runtime.snapshot(&list_id);
                let items: &[TodoProjection] = snapshot.as_ref().map_or(&[], |list| &list.items);
                if items.is_empty() {
                    return Ok("No tasks found".to_string());
                }
                // Pre-build a status lookup so the per-row blocker filter is
                // O(B) rather than O(B * N).
                let status_by_id: HashMap<&str, TodoStatus> =
                    items.iter().map(|t| (t.id.as_str(), t.status)).collect();

                let mut out = String::new();
                for todo in items {
                    let _ = write!(out, "#{} [{}] {}", todo.id, todo.status, todo.subject);
                    if let Some(owner) = todo.owner.as_ref() {
                        let _ = write!(out, " (owner: {owner})");
                    }
                    // Uncompleted blockers only — Claude's convention.
                    let mut blockers = todo.blocked_by.iter().filter(|id| {
                        status_by_id
                            .get(id.as_str())
                            .copied()
                            .is_none_or(|s| s != TodoStatus::Completed)
                    });
                    if let Some(first) = blockers.next() {
                        let _ = write!(out, " (blocked by: {first}");
                        for blocker in blockers {
                            let _ = write!(out, ", {blocker}");
                        }
                        out.push(')');
                    }
                    out.push('\n');
                }
                Ok(out.trim_end().to_string())
            })
        }),
        source:     ToolSource::Native,
    }
}

#[cfg(test)]
mod kimi_todo_tests {
    use std::sync::Arc;

    use serde_json::json;
    use tokio_util::sync::CancellationToken;

    use super::tests::SilentEmitter;
    use super::*;
    use crate::sandbox::Sandbox;
    use crate::test_support::MockSandbox;

    fn ctx() -> ToolContext {
        let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
        ToolContext {
            env,
            cancel: CancellationToken::new(),
            tool_env_provider: None,
            session_id: Some("ses_kimi".to_string()),
            root_session_id: Some("ses_kimi".to_string()),
            tool_call_id: None,
            agent_event_emitter: Some(Arc::new(SilentEmitter)),
        }
    }

    async fn call(tool: &RegisteredTool, args: serde_json::Value) -> Result<String, String> {
        (tool.executor)(args, ctx()).await
    }

    #[tokio::test]
    async fn replaces_the_whole_list_and_reads_it_back() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_todo_list_tool(runtime);

        call(
            &tool,
            json!({"todos": [
                {"title": "read the config", "status": "done"},
                {"title": "patch the parser", "status": "in_progress"},
                {"title": "add a test", "status": "pending"}
            ]}),
        )
        .await
        .unwrap();

        // Read mode: `todos` omitted entirely.
        let listed = call(&tool, json!({})).await.unwrap();
        assert!(listed.contains("[done] read the config"), "{listed}");
        assert!(
            listed.contains("[in_progress] patch the parser"),
            "{listed}"
        );

        // Re-submitting a shorter list drops the missing entries.
        call(
            &tool,
            json!({"todos": [{"title": "add a test", "status": "done"}]}),
        )
        .await
        .unwrap();
        let listed = call(&tool, json!({})).await.unwrap();
        assert!(listed.contains("[done] add a test"), "{listed}");
        assert!(!listed.contains("patch the parser"), "{listed}");
    }

    #[tokio::test]
    async fn empty_array_clears_the_list() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_todo_list_tool(runtime);
        call(
            &tool,
            json!({"todos": [{"title": "x", "status": "pending"}]}),
        )
        .await
        .unwrap();
        call(&tool, json!({"todos": []})).await.unwrap();
        assert_eq!(
            call(&tool, json!({})).await.unwrap(),
            "The todo list is empty."
        );
    }

    /// Kimi Code spells the terminal status `done`; `completed` is the
    /// Anthropic/Codex spelling and must not be silently accepted.
    #[tokio::test]
    async fn status_vocabulary_is_kimi_codes() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_todo_list_tool(runtime);
        let err = call(
            &tool,
            json!({"todos": [{"title": "x", "status": "completed"}]}),
        )
        .await
        .unwrap_err();
        assert!(err.contains("expected pending|in_progress|done"), "{err}");
    }

    #[tokio::test]
    async fn duplicate_titles_are_rejected() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_todo_list_tool(runtime);
        let err = call(
            &tool,
            json!({"todos": [
                {"title": "same", "status": "pending"},
                {"title": "same", "status": "done"}
            ]}),
        )
        .await
        .unwrap_err();
        assert!(err.contains("must be unique"), "{err}");
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use tokio_util::sync::CancellationToken;

    use super::*;
    use crate::sandbox::Sandbox;
    use crate::test_support::MockSandbox;
    use crate::tool_registry::{AgentEventEmitter, ToolContext};
    use crate::types::AgentEvent;

    #[derive(Default)]
    pub(super) struct SilentEmitter;
    impl AgentEventEmitter for SilentEmitter {
        fn emit(&self, _event: AgentEvent) {}
    }

    fn ctx_for(session: &str, root: &str) -> ToolContext {
        let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
        ToolContext {
            env,
            cancel: CancellationToken::new(),
            tool_env_provider: None,
            session_id: Some(session.to_string()),
            root_session_id: Some(root.to_string()),
            tool_call_id: None,
            agent_event_emitter: Some(Arc::new(SilentEmitter)),
        }
    }

    fn openai_list(session: &str) -> String {
        TodoListKind::OpenAiPlan.list_id(session)
    }

    fn anthropic_list(session: &str) -> String {
        TodoListKind::AnthropicTasks.list_id(session)
    }

    #[tokio::test]
    async fn update_plan_creates_initial_steps() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_update_plan_tool(runtime.clone());
        let ctx = ctx_for("ses_a", "ses_a");
        let out = (tool.executor)(
            serde_json::json!({
                "plan": [
                    {"step": "a", "status": "pending"},
                    {"step": "b", "status": "in_progress"},
                ]
            }),
            ctx,
        )
        .await
        .unwrap();
        assert_eq!(out, "Plan updated");
        let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
        assert_eq!(list.items.len(), 2);
        assert_eq!(list.items[0].subject, "a");
        assert_eq!(list.items[1].subject, "b");
        assert_eq!(list.items[1].status, TodoStatus::InProgress);
    }

    #[tokio::test]
    async fn update_plan_updates_status_and_order() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_update_plan_tool(runtime.clone());
        (tool.executor)(
            serde_json::json!({
                "plan": [
                    {"step": "a", "status": "pending"},
                    {"step": "b", "status": "pending"},
                ]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (tool.executor)(
            serde_json::json!({
                "plan": [
                    {"step": "b", "status": "in_progress"},
                    {"step": "a", "status": "completed"},
                ]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
        assert_eq!(list.items.len(), 2);
        assert_eq!(list.items[0].subject, "b");
        assert_eq!(list.items[0].status, TodoStatus::InProgress);
        assert_eq!(list.items[1].subject, "a");
        assert_eq!(list.items[1].status, TodoStatus::Completed);
    }

    #[tokio::test]
    async fn update_plan_deletes_omitted_steps() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_update_plan_tool(runtime.clone());
        (tool.executor)(
            serde_json::json!({
                "plan": [
                    {"step": "a", "status": "pending"},
                    {"step": "b", "status": "pending"},
                    {"step": "c", "status": "pending"},
                ]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (tool.executor)(
            serde_json::json!({
                "plan": [{"step": "b", "status": "completed"}]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
        assert_eq!(list.items.len(), 1);
        assert_eq!(list.items[0].subject, "b");
    }

    #[tokio::test]
    async fn update_plan_rejects_duplicate_steps() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_update_plan_tool(runtime);
        let err = (tool.executor)(
            serde_json::json!({
                "plan": [
                    {"step": "same", "status": "pending"},
                    {"step": "same", "status": "completed"},
                ]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap_err();
        assert!(err.contains("Duplicate plan step"), "got: {err}");
    }

    #[tokio::test]
    async fn update_plan_subagent_writes_different_list_than_parent() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_update_plan_tool(runtime.clone());
        (tool.executor)(
            serde_json::json!({"plan": [{"step": "parent_step", "status": "pending"}]}),
            ctx_for("ses_parent", "ses_parent"),
        )
        .await
        .unwrap();
        (tool.executor)(
            serde_json::json!({"plan": [{"step": "child_step", "status": "pending"}]}),
            // Subagent session: own session_id is distinct from root.
            ctx_for("ses_child", "ses_parent"),
        )
        .await
        .unwrap();
        let parent = runtime.snapshot(&openai_list("ses_parent")).unwrap();
        let child = runtime.snapshot(&openai_list("ses_child")).unwrap();
        assert_eq!(parent.items.len(), 1);
        assert_eq!(parent.items[0].subject, "parent_step");
        assert_eq!(child.items.len(), 1);
        assert_eq!(child.items[0].subject, "child_step");
    }

    #[tokio::test]
    async fn task_create_returns_numeric_id_and_message() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let out = (create.executor)(
            serde_json::json!({"subject": "Do thing", "description": "details"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        assert_eq!(out, "Task #1 created successfully: Do thing");
        let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
        assert_eq!(list.items.len(), 1);
        assert_eq!(list.items[0].id, "1");
        assert_eq!(list.items[0].subject, "Do thing");
        assert_eq!(list.items[0].description, "details");
    }

    #[test]
    fn anthropic_task_tool_descriptions_are_concise() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());
        let list = make_task_list_tool(runtime);

        assert!(
            create
                .definition
                .description
                .contains("Create pending tasks")
        );
        assert!(create.definition.description.contains("activeForm"));
        assert!(update.definition.description.contains("pending"));
        assert!(update.definition.description.contains("deleted"));
        assert!(
            list.definition
                .description
                .contains("blocking dependencies")
        );

        let total_description_bytes = create.definition.description.len()
            + update.definition.description.len()
            + list.definition.description.len();
        assert!(total_description_bytes < 600);
        assert!(!create.definition.description.contains("##"));
        assert!(!update.definition.description.contains("```"));
    }

    #[tokio::test]
    async fn task_create_list_update_delete_cycle() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());
        let list_tool = make_task_list_tool(runtime.clone());

        (create.executor)(
            serde_json::json!({"subject": "First", "description": "desc"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (create.executor)(
            serde_json::json!({"subject": "Second", "description": "desc"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let listing = (list_tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
            .await
            .unwrap();
        assert!(listing.contains("#1 [pending] First"));
        assert!(listing.contains("#2 [pending] Second"));

        (update.executor)(
            serde_json::json!({"taskId": "1", "status": "completed"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({"taskId": "2", "status": "deleted"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let listing = (list_tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
            .await
            .unwrap();
        assert!(listing.contains("#1 [completed] First"));
        assert!(!listing.contains("#2"));
    }

    #[tokio::test]
    async fn task_update_metadata_merges_and_null_deletes() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());
        (create.executor)(
            serde_json::json!({"subject": "t", "description": "d", "metadata": {"k1": "v1"}}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({"taskId": "1", "metadata": {"k2": "v2"}}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({"taskId": "1", "metadata": {"k1": null}}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
        let meta = &list.items[0].metadata;
        assert!(!meta.contains_key("k1"));
        assert_eq!(meta.get("k2"), Some(&serde_json::json!("v2")));
    }

    #[tokio::test]
    async fn task_update_omitted_optional_strings_do_not_clear_existing_values() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());

        (create.executor)(
            serde_json::json!({
                "subject": "t",
                "description": "d",
                "activeForm": "doing t"
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({"taskId": "1", "owner": "alice"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({"taskId": "1", "metadata": {"k": "v"}}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();

        let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
        assert_eq!(list.items[0].active_form.as_deref(), Some("doing t"));
        assert_eq!(list.items[0].owner.as_deref(), Some("alice"));
    }

    #[tokio::test]
    async fn task_update_add_blocks_and_add_blocked_by_dedupe() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());
        (create.executor)(
            serde_json::json!({"subject": "t", "description": "d"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({
                "taskId": "1",
                "addBlocks": ["b1", "b2"],
                "addBlockedBy": ["c1"]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({
                "taskId": "1",
                "addBlocks": ["b1", "b3"]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
        assert_eq!(list.items[0].blocks, vec!["b1", "b2", "b3"]);
        assert_eq!(list.items[0].blocked_by, vec!["c1"]);
    }

    #[tokio::test]
    async fn task_list_empty_returns_no_tasks_found() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_task_list_tool(runtime);
        let out = (tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
            .await
            .unwrap();
        assert_eq!(out, "No tasks found");
    }

    #[tokio::test]
    async fn task_update_missing_task_returns_not_found() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_task_update_tool(runtime);
        let out = (tool.executor)(
            serde_json::json!({"taskId": "999", "status": "completed"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        assert_eq!(out, "Task not found");
    }

    #[tokio::test]
    async fn task_get_returns_full_task_details() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        let update = make_task_update_tool(runtime.clone());
        let get = make_task_get_tool(runtime);

        (create.executor)(
            serde_json::json!({
                "subject": "Investigate failing tests",
                "description": "Find the failing assertions and identify the smallest fix."
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        (update.executor)(
            serde_json::json!({
                "taskId": "1",
                "status": "in_progress",
                "owner": "agent-1",
                "addBlockedBy": ["2", "3"],
                "addBlocks": ["4"]
            }),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();

        let out = (get.executor)(
            serde_json::json!({"taskId": "1"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();

        assert_eq!(
            out,
            "\
Task #1: Investigate failing tests
Status: in_progress
Description: Find the failing assertions and identify the smallest fix.
Owner: agent-1
Blocked by: #2, #3
Blocks: #4"
        );
    }

    #[tokio::test]
    async fn task_get_missing_task_returns_not_found() {
        let runtime = Arc::new(TodoRuntime::new());
        let tool = make_task_get_tool(runtime);
        let out = (tool.executor)(
            serde_json::json!({"taskId": "999"}),
            ctx_for("ses_a", "ses_a"),
        )
        .await
        .unwrap();
        assert_eq!(out, "Task not found");
    }

    #[tokio::test]
    async fn parent_and_subagent_share_anthropic_task_list() {
        let runtime = Arc::new(TodoRuntime::new());
        let create = make_task_create_tool(runtime.clone());
        // Parent: session_id == root_session_id.
        (create.executor)(
            serde_json::json!({"subject": "p", "description": "d"}),
            ctx_for("ses_parent", "ses_parent"),
        )
        .await
        .unwrap();
        // Subagent: own session id but inherits parent's root.
        (create.executor)(
            serde_json::json!({"subject": "c", "description": "d"}),
            ctx_for("ses_child", "ses_parent"),
        )
        .await
        .unwrap();

        // Only one list keyed by the parent root.
        assert!(runtime.snapshot(&anthropic_list("ses_child")).is_none());
        let list = runtime.snapshot(&anthropic_list("ses_parent")).unwrap();
        assert_eq!(list.items.len(), 2);
    }
}
