//! The translation seam: `hagent-core` `StreamEvent`s → ACP `SessionUpdate`s.
//!
//! Pure functions, no I/O — this is the unit-tested core. Tool-call translation
//! keys updates by the wire `call_id`/`item_id` via [`TranslateState`] so the
//! first sighting of a tool emits a `ToolCall` and later ones a `ToolCallUpdate`
//! (ACP's create-then-update lifecycle).
//!
//! **Shell output gate (Hadrian quirk #4):** a `shell_call`'s output arrives in
//! a *separate* `shell_call_output` item, *after* the `shell_call` itself reports
//! `status: completed`. So we keep a shell tool call `InProgress` through its own
//! completion and only mark it `Completed` when the matching output item lands
//! (joined by `call_id`). Marking it complete on the `shell_call`'s own status
//! would race the output and render an empty command.

use std::collections::HashMap;
use std::path::PathBuf;

use agent_client_protocol::schema::{
    ContentChunk, SessionUpdate, StopReason, ToolCall, ToolCallContent, ToolCallId,
    ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
};
use hagent_core::protocol::event::{KnownEvent, StreamEvent};
use hagent_core::protocol::items::{
    FileSearchCallItem, KnownItem, McpCallItem, OutputItem, ShellCallItem, ShellCallOutputItem,
    WebSearchCallItem,
};

/// Per-turn translation state: maps a wire call/item id to the ACP `ToolCallId`
/// we assigned, so a repeat sighting becomes a `ToolCallUpdate` rather than a
/// duplicate `ToolCall`.
#[derive(Default)]
pub struct TranslateState {
    seen: HashMap<String, ToolCallId>,
}

/// Translate one `StreamEvent` into an ACP `SessionUpdate`, if it maps to one.
///
/// Text/reasoning deltas become message/thought chunks; tool items become
/// `ToolCall`/`ToolCallUpdate`. Unmodeled events return `None`.
pub fn to_update(event: &StreamEvent, state: &mut TranslateState) -> Option<SessionUpdate> {
    let StreamEvent::Known(ev) = event else {
        return None;
    };
    match ev {
        KnownEvent::OutputTextDelta { delta, .. } => Some(SessionUpdate::AgentMessageChunk(
            ContentChunk::new(delta.clone().into()),
        )),
        KnownEvent::ReasoningSummaryDelta { delta, .. }
        | KnownEvent::ReasoningTextDelta { delta, .. } => Some(SessionUpdate::AgentThoughtChunk(
            ContentChunk::new(delta.clone().into()),
        )),
        KnownEvent::OutputItemAdded { item, .. } => item_update(item, false, state),
        KnownEvent::OutputItemDone { item, .. } => item_update(item, true, state),
        _ => None,
    }
}

/// Fold a terminal event into the turn's [`StopReason`]. Cancellation is handled
/// by the turn loop's select, not here.
pub fn note_stop(event: &StreamEvent, stop: &mut StopReason) {
    let StreamEvent::Known(ev) = event else {
        return;
    };
    match ev {
        KnownEvent::Completed { .. } => *stop = StopReason::EndTurn,
        KnownEvent::Incomplete { .. } => *stop = StopReason::MaxTokens,
        KnownEvent::Failed { .. } | KnownEvent::Error { .. } => *stop = StopReason::Refusal,
        _ => {}
    }
}

/// Translate an output item (from `output_item.added`/`.done`) to a tool-call
/// update. `done` distinguishes the `.done` event (terminal status) from
/// `.added`. Message/reasoning items stream via deltas (handled above) and MCP
/// approval requests are handled by the turn loop, so all return `None` here.
fn item_update(item: &OutputItem, done: bool, state: &mut TranslateState) -> Option<SessionUpdate> {
    let OutputItem::Known(known) = item else {
        return None;
    };
    match known.as_ref() {
        KnownItem::ShellCall(sc) => Some(shell_call(sc, state)),
        KnownItem::ShellCallOutput(out) => Some(shell_output(out, state)),
        KnownItem::WebSearchCall(w) => web_search(w, done, state),
        KnownItem::FileSearchCall(f) => file_search(f, done, state),
        KnownItem::McpCall(m) => mcp_call(m, done, state),
        _ => None,
    }
}

/// A `shell_call`: an `Execute` tool call (or `Edit`, when it drives the bundled
/// `edit.py`). Always reported `InProgress` — completion waits for the separate
/// `shell_call_output` item (see the module-level quirk note).
fn shell_call(sc: &ShellCallItem, state: &mut TranslateState) -> SessionUpdate {
    let commands: Vec<&str> = sc
        .action
        .as_ref()
        .map(|a| a.commands.iter().map(String::as_str).collect())
        .unwrap_or_default();
    let edit_path = edit_target(&commands);

    let (title, kind, locations) = match &edit_path {
        Some(path) => (
            format!("edit {}", path.display()),
            ToolKind::Edit,
            vec![ToolCallLocation::new(path.clone())],
        ),
        None => (shell_title(&commands), ToolKind::Execute, vec![]),
    };
    let content = vec![ToolCallContent::from(commands.join("\n"))];

    emit(
        state,
        &sc.call_id,
        title,
        kind,
        ToolCallStatus::InProgress,
        content,
        locations,
    )
}

/// A `shell_call_output`: the terminal update for the matching `shell_call`,
/// joined by `call_id`. Marks the tool call `Completed`/`Failed` with the output.
fn shell_output(out: &ShellCallOutputItem, state: &mut TranslateState) -> SessionUpdate {
    let status = if out.status.as_deref() == Some("failed") {
        ToolCallStatus::Failed
    } else {
        ToolCallStatus::Completed
    };
    let text = out.combined_output();
    let content = if text.is_empty() {
        vec![]
    } else {
        vec![ToolCallContent::from(text)]
    };
    emit(
        state,
        &out.call_id,
        "shell".to_string(),
        ToolKind::Execute,
        status,
        content,
        vec![],
    )
}

fn web_search(
    w: &WebSearchCallItem,
    done: bool,
    state: &mut TranslateState,
) -> Option<SessionUpdate> {
    let key = w.id.as_deref()?;
    Some(emit(
        state,
        key,
        "web search".to_string(),
        ToolKind::Fetch,
        map_status(w.status.as_deref(), done),
        vec![],
        vec![],
    ))
}

fn file_search(
    f: &FileSearchCallItem,
    done: bool,
    state: &mut TranslateState,
) -> Option<SessionUpdate> {
    let key = f.id.as_deref()?;
    let content = if f.queries.is_empty() {
        vec![]
    } else {
        vec![ToolCallContent::from(f.queries.join("\n"))]
    };
    Some(emit(
        state,
        key,
        "file search".to_string(),
        ToolKind::Search,
        map_status(f.status.as_deref(), done),
        content,
        vec![],
    ))
}

/// An `mcp_call`: an `Other`-kind tool call carrying the arguments as `raw_input`
/// and the result/error as content. Built directly (rather than via [`emit`]) so
/// it can attach `raw_input`/`raw_output`.
fn mcp_call(m: &McpCallItem, done: bool, state: &mut TranslateState) -> Option<SessionUpdate> {
    let key = m.id.as_deref()?;
    let status = if m.error.is_some() {
        ToolCallStatus::Failed
    } else {
        map_status(m.status.as_deref(), done || m.output.is_some())
    };
    let raw_input = parse_json_or_string(&m.arguments);
    let result_text = m.error.clone().or_else(|| m.output.clone());
    let content: Vec<ToolCallContent> = result_text
        .filter(|s| !s.is_empty())
        .map(|s| vec![ToolCallContent::from(s)])
        .unwrap_or_default();

    let update = match state.seen.get(key).cloned() {
        Some(id) => {
            let mut fields = ToolCallUpdateFields::new()
                .status(status)
                .raw_input(raw_input);
            if !content.is_empty() {
                fields = fields.content(content);
            }
            SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id, fields))
        }
        None => {
            let id = ToolCallId::new(key.to_string());
            state.seen.insert(key.to_string(), id.clone());
            let mut tc = ToolCall::new(id, format!("{}::{}", m.server_label, m.name))
                .kind(ToolKind::Other)
                .status(status)
                .raw_input(raw_input);
            if !content.is_empty() {
                tc = tc.content(content);
            }
            SessionUpdate::ToolCall(tc)
        }
    };
    Some(update)
}

/// Emit a `ToolCall` on first sighting of `key`, or a `ToolCallUpdate` after.
fn emit(
    state: &mut TranslateState,
    key: &str,
    title: String,
    kind: ToolKind,
    status: ToolCallStatus,
    content: Vec<ToolCallContent>,
    locations: Vec<ToolCallLocation>,
) -> SessionUpdate {
    match state.seen.get(key).cloned() {
        Some(id) => {
            let mut fields = ToolCallUpdateFields::new().status(status);
            if !content.is_empty() {
                fields = fields.content(content);
            }
            if !locations.is_empty() {
                fields = fields.locations(locations);
            }
            SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id, fields))
        }
        None => {
            let id = ToolCallId::new(key.to_string());
            state.seen.insert(key.to_string(), id.clone());
            let mut tc = ToolCall::new(id, title).kind(kind).status(status);
            if !content.is_empty() {
                tc = tc.content(content);
            }
            if !locations.is_empty() {
                tc = tc.locations(locations);
            }
            SessionUpdate::ToolCall(tc)
        }
    }
}

/// Map a Responses tool-call status string to an ACP `ToolCallStatus`. `done`
/// (the `output_item.done` event) defaults an unknown/absent status to completed.
fn map_status(status: Option<&str>, done: bool) -> ToolCallStatus {
    match status {
        Some("completed") => ToolCallStatus::Completed,
        Some("failed") | Some("incomplete") => ToolCallStatus::Failed,
        Some("in_progress") | Some("searching") => ToolCallStatus::InProgress,
        _ if done => ToolCallStatus::Completed,
        _ => ToolCallStatus::InProgress,
    }
}

/// A one-line title for a shell command (first non-empty line, truncated).
fn shell_title(commands: &[&str]) -> String {
    let first = commands
        .iter()
        .flat_map(|c| c.lines())
        .map(str::trim)
        .find(|l| !l.is_empty())
        .unwrap_or("shell");
    truncate(first, 72)
}

/// If the shell command drives the bundled `edit.py`, return the file path it
/// edits (the first positional after the subcommand, skipping value-flags). This
/// is a pragmatic subset of `hagent`'s `parse_edit_call` — enough to surface an
/// `Edit` card with a follow-along location.
fn edit_target(commands: &[&str]) -> Option<PathBuf> {
    for cmd in commands {
        for line in cmd.lines() {
            if !line.contains("edit.py") {
                continue;
            }
            let tokens: Vec<&str> = line.split_whitespace().collect();
            let idx = tokens.iter().position(|t| t.ends_with("edit.py"))?;
            // tokens after edit.py: <op> [flags...] <path>
            let mut rest = tokens[idx + 1..].iter();
            let _op = rest.next()?;
            while let Some(tok) = rest.next() {
                match *tok {
                    "--range" => {
                        rest.next();
                        rest.next();
                    }
                    "--line" | "--insert-line" => {
                        rest.next();
                    }
                    flag if flag.starts_with('-') => {}
                    path => return Some(PathBuf::from(path)),
                }
            }
        }
    }
    None
}

/// Parse a tool-argument string as JSON, falling back to a JSON string value.
fn parse_json_or_string(s: &str) -> serde_json::Value {
    serde_json::from_str(s).unwrap_or_else(|_| serde_json::Value::String(s.to_string()))
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
        out.push('…');
        out
    }
}

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

    fn event(value: serde_json::Value) -> StreamEvent {
        serde_json::from_value(value).expect("valid StreamEvent")
    }

    #[test]
    fn text_delta_becomes_agent_message_chunk() {
        let mut st = TranslateState::default();
        let ev = event(
            json!({"type": "response.output_text.delta", "output_index": 0, "delta": "hello"}),
        );
        match to_update(&ev, &mut st) {
            Some(SessionUpdate::AgentMessageChunk(chunk)) => {
                let serialized = serde_json::to_value(&chunk).unwrap();
                assert_eq!(serialized["content"]["text"], "hello");
            }
            other => panic!("expected AgentMessageChunk, got {other:?}"),
        }
    }

    #[test]
    fn reasoning_delta_becomes_thought_chunk() {
        let mut st = TranslateState::default();
        let ev = event(
            json!({"type": "response.reasoning_summary_text.delta", "output_index": 0, "delta": "think"}),
        );
        assert!(matches!(
            to_update(&ev, &mut st),
            Some(SessionUpdate::AgentThoughtChunk(_))
        ));
    }

    #[test]
    fn shell_call_then_output_is_create_then_complete() {
        let mut st = TranslateState::default();

        // 1. shell_call added -> ToolCall, InProgress, Execute.
        let added = event(json!({
            "type": "response.output_item.added",
            "output_index": 0,
            "item": {"type": "shell_call", "call_id": "c1",
                     "action": {"commands": ["ls -la"]}, "status": "in_progress"}
        }));
        let id = match to_update(&added, &mut st) {
            Some(SessionUpdate::ToolCall(tc)) => {
                assert_eq!(tc.status, ToolCallStatus::InProgress);
                assert_eq!(tc.kind, ToolKind::Execute);
                tc.tool_call_id
            }
            other => panic!("expected ToolCall, got {other:?}"),
        };

        // 2. shell_call done (status completed) must NOT complete the call yet —
        //    output hasn't arrived (quirk #4). Same id, still InProgress.
        let done = event(json!({
            "type": "response.output_item.done",
            "output_index": 0,
            "item": {"type": "shell_call", "call_id": "c1",
                     "action": {"commands": ["ls -la"]}, "status": "completed"}
        }));
        match to_update(&done, &mut st) {
            Some(SessionUpdate::ToolCallUpdate(u)) => {
                assert_eq!(u.tool_call_id, id);
                assert_eq!(u.fields.status, Some(ToolCallStatus::InProgress));
            }
            other => panic!("expected ToolCallUpdate InProgress, got {other:?}"),
        }

        // 3. shell_call_output -> the terminal Completed update with output.
        let output = event(json!({
            "type": "response.output_item.done",
            "output_index": 1,
            "item": {"type": "shell_call_output", "call_id": "c1", "status": "completed",
                     "output": [{"stdout": "total 0\n", "stderr": ""}]}
        }));
        match to_update(&output, &mut st) {
            Some(SessionUpdate::ToolCallUpdate(u)) => {
                assert_eq!(u.tool_call_id, id, "output joins the call by call_id");
                assert_eq!(u.fields.status, Some(ToolCallStatus::Completed));
                assert!(u.fields.content.is_some());
            }
            other => panic!("expected terminal ToolCallUpdate, got {other:?}"),
        }
    }

    #[test]
    fn edit_py_shell_call_becomes_edit_kind_with_location() {
        let mut st = TranslateState::default();
        let added = event(json!({
            "type": "response.output_item.added",
            "output_index": 0,
            "item": {"type": "shell_call", "call_id": "c2",
                     "action": {"commands": ["python3 /skills/abc/scripts/edit.py str-replace src/main.rs"]},
                     "status": "in_progress"}
        }));
        match to_update(&added, &mut st) {
            Some(SessionUpdate::ToolCall(tc)) => {
                assert_eq!(tc.kind, ToolKind::Edit);
                assert_eq!(tc.locations.len(), 1);
                assert_eq!(tc.locations[0].path, PathBuf::from("src/main.rs"));
            }
            other => panic!("expected Edit ToolCall, got {other:?}"),
        }
    }

    #[test]
    fn mcp_call_carries_args_and_result() {
        let mut st = TranslateState::default();
        let added = event(json!({
            "type": "response.output_item.added",
            "output_index": 0,
            "item": {"type": "mcp_call", "id": "m1", "server_label": "github",
                     "name": "list_repos", "arguments": "{\"org\":\"acme\"}", "status": "in_progress"}
        }));
        match to_update(&added, &mut st) {
            Some(SessionUpdate::ToolCall(tc)) => {
                assert_eq!(tc.kind, ToolKind::Other);
                assert_eq!(tc.title, "github::list_repos");
                assert_eq!(tc.raw_input, Some(json!({"org": "acme"})));
            }
            other => panic!("expected mcp ToolCall, got {other:?}"),
        }

        let done = event(json!({
            "type": "response.output_item.done",
            "output_index": 0,
            "item": {"type": "mcp_call", "id": "m1", "server_label": "github",
                     "name": "list_repos", "arguments": "{}", "status": "completed",
                     "output": "[\"repo1\"]"}
        }));
        match to_update(&done, &mut st) {
            Some(SessionUpdate::ToolCallUpdate(u)) => {
                assert_eq!(u.fields.status, Some(ToolCallStatus::Completed));
                assert!(u.fields.content.is_some());
            }
            other => panic!("expected mcp ToolCallUpdate, got {other:?}"),
        }
    }

    #[test]
    fn unknown_event_is_ignored() {
        let mut st = TranslateState::default();
        let ev = event(json!({"type": "response.created", "response": {}}));
        assert!(to_update(&ev, &mut st).is_none());
    }

    #[test]
    fn approval_request_item_is_not_a_session_update() {
        let mut st = TranslateState::default();
        let ev = event(json!({
            "type": "response.output_item.added",
            "output_index": 0,
            "item": {"type": "mcp_approval_request", "id": "a1", "server_label": "github",
                     "name": "delete_repo", "arguments": "{}"}
        }));
        assert!(to_update(&ev, &mut st).is_none());
    }
}
