#![cfg_attr(test, allow(clippy::expect_used))]

use motosan_agent_tool::ToolResult;
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize)]
pub enum ProgressChunk {
    Stdout(Vec<u8>),
    Stderr(Vec<u8>),
    Status(String),
}

impl From<crate::tools::ToolProgressChunk> for ProgressChunk {
    fn from(c: crate::tools::ToolProgressChunk) -> Self {
        use crate::tools::ToolProgressChunk as TPC;
        match c {
            TPC::Stdout(b) => Self::Stdout(b),
            TPC::Stderr(b) => Self::Stderr(b),
            TPC::Status(s) => Self::Status(s),
        }
    }
}

/// User's answer to a `PermissionRequested` prompt, carried back from the
/// front-end to the binary so the binary can update the session cache.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionResolution {
    pub tool: String,
    pub args: serde_json::Value,
    pub choice: PermissionChoice,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionChoice {
    AllowOnce,
    AllowSession,
    Deny,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum Command {
    SendUserMessage {
        text: String,
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        attachments: Vec<crate::user_message::Attachment>,
    },
    CancelAgent,
    Quit,
    ResolvePermission(PermissionResolution),
    /// `!cmd` / `!!cmd` — run a shell command typed directly by the user.
    /// `send_to_llm` is true for `!cmd` (output becomes a user message),
    /// false for `!!cmd` (output shown in the transcript only).
    RunInlineBash {
        command: String,
        send_to_llm: bool,
    },
    /// Manual context compaction, triggered by the `/compact` slash command.
    Compact,
    /// `/new` — replace the live session with a fresh empty one.
    NewSession,
    /// `/clone` — copy the current session to a new independent file and
    /// switch to it. Handled by `forward_commands`.
    CloneSession,
    /// `--rpc` read query: reply with the session's `Vec<Message>`
    /// history via `UiEvent::MessagesSnapshot`. No effect on the agent.
    GetMessages,
    /// `--rpc` read query: reply with a small status snapshot via
    /// `UiEvent::StateSnapshot`. No effect on the agent.
    GetState,
    /// `--rpc` read query: reply with the registered extensions + load
    /// diagnostics via `UiEvent::ExtensionList`. No agent effect.
    ListExtensions,
    /// Invoke an extension-registered slash command.
    /// Emitted by the TUI's slash palette when the user selects a command
    /// owned by an extension (not a built-in).
    InvokeExtensionCommand {
        name: String,
        args: String,
    },
    /// `/model` — rebuild the live session under a different model.
    SwitchModel(crate::model::ModelId),
    /// `/resume` — replace the live session with a stored one, by id.
    LoadSession(String),
    /// `/fork` — continue from an earlier entry, creating a branch.
    ForkFrom {
        from: String,
        message: String,
    },
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExtensionInfo {
    pub name: String,
    pub hooks: Vec<String>,
    pub commands: Vec<String>,
    /// `true` if the extension's binary exists and is executable at the
    /// configured path. `false` if a load-time diagnostic blocked it.
    pub healthy: bool,
    /// Load-time diagnostic if `!healthy`. `None` otherwise.
    pub diagnostic: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum UiEvent {
    AgentTurnStarted,
    AgentThinking,
    AgentTextDelta(String),
    AgentMessageComplete(String),
    ToolCallStarted {
        id: String,
        name: String,
        args: Value,
    },
    ToolCallProgress {
        id: String,
        chunk: ProgressChunk,
    },
    ToolCallCompleted {
        id: String,
        result: UiToolResult,
    },
    AgentTurnComplete,
    PermissionRequested {
        tool: String,
        args: serde_json::Value,
        #[serde(skip)]
        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
    },
    /// Output of a `!!cmd` inline-bash run, for transcript display.
    InlineBashOutput {
        command: String,
        output: String,
    },
    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
    /// stored one). Carries the new transcript so the TUI can rebuild
    /// `state.messages`. Empty Vec = a fresh session.
    SessionReplaced(Vec<motosan_agent_loop::Message>),
    /// The active model changed successfully.
    ModelSwitched(crate::model::ModelId),
    /// Refreshed `/fork` candidate list (active-branch user messages,
    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
    /// at startup and after every turn so the picker is never stale.
    ForkCandidates(Vec<(String, String)>),
    /// Refreshed session branch tree — the binary emits this at startup
    /// and after every turn so the `/tree` view is never stale.
    BranchTree(
        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
        motosan_agent_loop::BranchTree,
    ),
    /// Reply to `Command::GetMessages` — the session's persisted message
    /// history. RPC clients use this to snapshot transcript state.
    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
    /// Reply to `Command::GetState` — a small status snapshot for RPC
    /// clients (the TUI holds these in `AppState` so does not need it).
    StateSnapshot {
        session_id: String,
        model: String,
        active_turn: bool,
    },
    /// Reply to `Command::ListExtensions` — the registered extensions
    /// + per-extension health status.
    ExtensionList {
        extensions: Vec<ExtensionInfo>,
    },
    Error(String),
    /// Emitted when an attachment in a `UserMessage` failed validation
    /// (path missing, unsupported extension, oversize, unreadable). No turn
    /// is started; the agent stays in `Idle`. See spec §3 / §4.4.
    AttachmentError {
        kind: crate::user_message::AttachmentErrorKind,
        message: String,
    },
    /// An extension cancelled a `before_user_message` or
    /// `session_before_switch` hook. The TUI rolls back any optimistic
    /// transcript push (via `state.pending_turn_submit`) and restores
    /// the composer buffer. See spec §4.4.
    ExtensionCancelled {
        extension_name: String,
        reason: Option<String>,
    },
    /// Surface a transcript notice from an extension `reply` action,
    /// `/image` errors, or other non-fatal informational events.
    Notice {
        title: String,
        body: String,
    },
}

#[derive(Debug, Clone, Serialize)]
pub struct UiToolResult {
    pub is_error: bool,
    pub text: String,
}

impl From<&ToolResult> for UiToolResult {
    fn from(r: &ToolResult) -> Self {
        Self {
            is_error: r.is_error,
            text: format!("{r:?}"),
        }
    }
}

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

    #[test]
    fn attachment_error_event_serializes_with_kind_and_message() {
        use crate::user_message::AttachmentErrorKind;

        let ev = UiEvent::AttachmentError {
            kind: AttachmentErrorKind::NotFound,
            message: "image not found: /tmp/foo.png".into(),
        };
        let json = serde_json::to_string(&ev).expect("serialize");
        // UiEvent uses tag = "type", content = "payload", rename_all = "snake_case"
        assert_eq!(
            json,
            r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
        );
    }

    #[test]
    fn compact_command_is_constructible() {
        let c = Command::Compact;
        assert_eq!(c, Command::Compact);
        assert!(format!("{c:?}").contains("Compact"));
    }

    #[test]
    fn d2_command_and_event_variants_construct() {
        let _ = Command::NewSession;
        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
        let _ = Command::LoadSession("sess-id".into());
        let e = UiEvent::SessionReplaced(Vec::new());
        assert!(format!("{e:?}").contains("SessionReplaced"));
        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
        assert!(format!("{e:?}").contains("ModelSwitched"));
    }

    #[test]
    fn clone_session_command_constructs() {
        let c = Command::CloneSession;
        assert!(format!("{c:?}").contains("CloneSession"));
    }

    #[test]
    fn read_query_commands_round_trip() {
        for c in [Command::GetMessages, Command::GetState] {
            let line = serde_json::to_string(&c).expect("serialize");
            assert!(line.contains("\"type\":"), "{line}");
            let back: Command = serde_json::from_str(&line).expect("deserialize");
            assert_eq!(c, back);
        }
        // Unit variants serialize as bare `{"type":"…"}`.
        assert_eq!(
            serde_json::to_string(&Command::GetMessages).expect("ser"),
            r#"{"type":"get_messages"}"#
        );
    }

    #[test]
    fn fork_protocol_variants_construct() {
        let c = Command::ForkFrom {
            from: "e1".into(),
            message: "hi".into(),
        };
        assert!(format!("{c:?}").contains("ForkFrom"));
        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
        assert!(format!("{e:?}").contains("ForkCandidates"));
    }

    #[test]
    fn branch_tree_event_constructs() {
        let tree = motosan_agent_loop::BranchTree {
            nodes: Vec::new(),
            root: None,
            active_leaf: None,
        };
        let e = UiEvent::BranchTree(tree);
        assert!(format!("{e:?}").contains("BranchTree"));
    }

    #[test]
    fn run_inline_bash_command_is_constructible() {
        let c = Command::RunInlineBash {
            command: "ls".into(),
            send_to_llm: true,
        };
        assert!(format!("{c:?}").contains("RunInlineBash"));
    }

    #[test]
    fn permission_protocol_variants_are_constructible() {
        let command = Command::ResolvePermission(PermissionResolution {
            tool: "bash".into(),
            args: serde_json::json!({"command": "echo hi"}),
            choice: PermissionChoice::AllowSession,
        });
        assert!(format!("{command:?}").contains("ResolvePermission"));
        assert!(format!("{command:?}").contains("AllowSession"));

        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
        let event = UiEvent::PermissionRequested {
            tool: "bash".into(),
            args: serde_json::json!({"command": "echo hi"}),
            resolver,
        };
        assert!(format!("{event:?}").contains("PermissionRequested"));
    }

    #[test]
    fn command_round_trips_through_json() {
        let cases = vec![
            Command::SendUserMessage {
                text: "hi".into(),
                attachments: Vec::new(),
            },
            Command::CancelAgent,
            Command::Quit,
            Command::Compact,
            Command::NewSession,
            Command::CloneSession,
            Command::RunInlineBash {
                command: "ls".into(),
                send_to_llm: true,
            },
            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
            Command::LoadSession("sess-1".into()),
            Command::ForkFrom {
                from: "e1".into(),
                message: "hi".into(),
            },
            Command::ResolvePermission(PermissionResolution {
                tool: "bash".into(),
                args: serde_json::json!({"command": "echo hi"}),
                choice: PermissionChoice::AllowSession,
            }),
        ];
        for c in cases {
            let line = serde_json::to_string(&c).expect("serialize");
            assert!(line.contains("\"type\":"), "missing type tag: {line}");
            let back: Command = serde_json::from_str(&line).expect("deserialize");
            assert_eq!(c, back, "round-trip mismatch for {line}");
        }
    }

    #[test]
    fn command_send_user_message_round_trip() {
        let line = serde_json::to_string(&Command::SendUserMessage {
            text: "hi".into(),
            attachments: Vec::new(),
        })
        .expect("serialize");
        assert_eq!(
            line,
            r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
        );
        let back: Command = serde_json::from_str(&line).expect("deserialize");
        assert_eq!(
            back,
            Command::SendUserMessage {
                text: "hi".into(),
                attachments: Vec::new(),
            }
        );
    }

    #[test]
    fn command_send_user_message_with_attachment_round_trip() {
        let cmd = Command::SendUserMessage {
            text: "look".into(),
            attachments: vec![crate::user_message::Attachment::Image {
                path: std::path::PathBuf::from("/tmp/foo.png"),
            }],
        };
        let line = serde_json::to_string(&cmd).expect("serialize");
        assert_eq!(
            line,
            r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
        );
        let back: Command = serde_json::from_str(&line).expect("deserialize");
        assert_eq!(back, cmd);
    }

    #[test]
    fn unit_command_uses_adjacent_tagging() {
        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
    }

    #[test]
    fn command_invoke_extension_command_serde_round_trip() {
        let cmd = Command::InvokeExtensionCommand {
            name: "todo".into(),
            args: "add buy milk".into(),
        };
        let json = serde_json::to_string(&cmd).expect("ok");
        assert_eq!(
            json,
            r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
        );
        let back: Command = serde_json::from_str(&json).expect("ok");
        assert_eq!(back, cmd);
    }

    #[test]
    fn ui_event_extension_cancelled_serializes() {
        let ev = UiEvent::ExtensionCancelled {
            extension_name: "dirty-repo-guard".into(),
            reason: Some("uncommitted changes".into()),
        };
        let json = serde_json::to_string(&ev).expect("ok");
        assert!(json.contains(r#""type":"extension_cancelled""#));
        assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
        assert!(json.contains(r#""reason":"uncommitted changes""#));
    }

    #[test]
    fn ui_event_notice_serializes() {
        let ev = UiEvent::Notice {
            title: "/todo".into(),
            body: "reply from fixture".into(),
        };
        let json = serde_json::to_string(&ev).expect("ok");
        assert!(json.contains(r#""type":"notice""#));
        assert!(json.contains(r#""title":"/todo""#));
        assert!(json.contains(r#""body":"reply from fixture""#));
    }

    #[test]
    fn snapshot_events_serialize_with_type_tag() {
        let m = UiEvent::MessagesSnapshot(Vec::new());
        let line = serde_json::to_string(&m).expect("serialize");
        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");

        let s = UiEvent::StateSnapshot {
            session_id: "sess-1".into(),
            model: "claude-opus-4-7".into(),
            active_turn: false,
        };
        let line = serde_json::to_string(&s).expect("serialize");
        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
        assert!(line.contains(r#""active_turn":false"#), "{line}");
    }

    #[test]
    fn ui_events_serialize_with_type_tag() {
        let events = vec![
            UiEvent::AgentTurnStarted,
            UiEvent::AgentThinking,
            UiEvent::AgentTextDelta("hi".into()),
            UiEvent::AgentMessageComplete("done".into()),
            UiEvent::AgentTurnComplete,
            UiEvent::InlineBashOutput {
                command: "ls".into(),
                output: "x".into(),
            },
            UiEvent::Notice {
                title: "note".into(),
                body: "body".into(),
            },
            UiEvent::SessionReplaced(Vec::new()),
            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
            UiEvent::Error("bad".into()),
            UiEvent::ToolCallStarted {
                id: "t1".into(),
                name: "bash".into(),
                args: serde_json::json!("ls"),
            },
            UiEvent::ToolCallProgress {
                id: "t1".into(),
                chunk: ProgressChunk::Status("running".into()),
            },
            UiEvent::ToolCallCompleted {
                id: "t1".into(),
                result: UiToolResult {
                    is_error: false,
                    text: "ok".into(),
                },
            },
        ];
        for e in &events {
            let line = serde_json::to_string(e).expect("serialize");
            assert!(line.contains("\"type\":"), "missing type tag: {line}");
        }
    }

    #[test]
    fn permission_requested_serializes_without_the_resolver() {
        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
        let e = UiEvent::PermissionRequested {
            tool: "bash".into(),
            args: serde_json::json!({"command": "echo hi"}),
            resolver,
        };
        let line = serde_json::to_string(&e).expect("serialize");
        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
        assert!(!line.contains("resolver"), "resolver leaked: {line}");
    }

    #[test]
    fn branch_tree_event_serializes_via_mapping() {
        let tree = motosan_agent_loop::BranchTree {
            nodes: vec![motosan_agent_loop::BranchNode {
                id: "n0".into(),
                parent: None,
                children: vec![],
                label: "root".into(),
            }],
            root: Some(0),
            active_leaf: Some(0),
        };
        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
        assert!(line.contains(r#""label":"root""#), "{line}");
    }

    #[test]
    fn command_list_extensions_serde_round_trip() {
        let cmd = Command::ListExtensions;
        let line = serde_json::to_string(&cmd).expect("serialize");
        assert_eq!(line, r#"{"type":"list_extensions"}"#);
        let back: Command = serde_json::from_str(&line).expect("deserialize");
        assert_eq!(back, cmd);
    }

    #[test]
    fn ui_event_extension_list_serializes() {
        let ev = UiEvent::ExtensionList {
            extensions: vec![ExtensionInfo {
                name: "dirty".into(),
                hooks: vec!["session_before_switch".into()],
                commands: Vec::new(),
                healthy: true,
                diagnostic: None,
            }],
        };
        let line = serde_json::to_string(&ev).expect("serialize");
        assert!(line.contains(r#""type":"extension_list""#));
        assert!(line.contains(r#""name":"dirty""#));
    }
}
