//! Transcript entry types and parsing.
//!
//! Claude Code stores conversation data in JSONL files at:
//! `~/.claude/projects/<project-path>/<session-id>.jsonl`

use std::collections::HashMap;

use chrono::Utc;
use serde::Deserialize;

use crate::model::{Event, EventBuilder, EventType};
use crate::util::truncate_string;

/// Information about a tool use, tracked for enriching PostToolUse events.
#[derive(Debug, Clone)]
pub struct ToolInfo {
    /// The tool name (e.g., "Bash", "Read", "Write")
    pub name: String,
    /// The tool input (command for Bash, etc.)
    pub input: serde_json::Value,
}

/// Map from tool_use_id to tool info.
pub type ToolInfoMap = HashMap<String, ToolInfo>;

/// A single entry in a Claude Code transcript JSONL file.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptEntry {
    /// Unique identifier for this entry
    pub uuid: String,

    /// Links to previous entry (for conversation tree)
    #[serde(default)]
    pub parent_uuid: Option<String>,

    /// Session identifier
    pub session_id: String,

    /// Entry timestamp (ISO 8601)
    pub timestamp: String,

    /// Entry type: "user" or "assistant"
    #[serde(rename = "type")]
    pub entry_type: String,

    /// Whether this is a sidechain request (subagent/auxiliary)
    /// CRITICAL for context size tracking - only main chain counts
    #[serde(default)]
    pub is_sidechain: bool,

    /// Message content
    #[serde(default)]
    pub message: Option<TranscriptMessage>,

    /// Working directory
    #[serde(default)]
    pub cwd: Option<String>,

    /// Git branch
    #[serde(default)]
    pub git_branch: Option<String>,

    /// Claude Code version
    #[serde(default)]
    pub version: Option<String>,

    /// Human-readable session slug
    #[serde(default)]
    pub slug: Option<String>,

    /// Tool use result (for user entries with tool results)
    #[serde(default)]
    pub tool_use_result: Option<serde_json::Value>,
}

/// Message content within a transcript entry.
#[derive(Debug, Clone, Deserialize)]
pub struct TranscriptMessage {
    /// Role: "user" or "assistant"
    pub role: String,

    /// Model used (for assistant messages)
    #[serde(default)]
    pub model: Option<String>,

    /// Message ID (for assistant messages)
    #[serde(default)]
    pub id: Option<String>,

    /// Token usage (for assistant messages)
    #[serde(default)]
    pub usage: Option<TokenUsage>,

    /// Content - can be either a plain string (for user prompts) or
    /// an array of content blocks (for tool results, assistant messages).
    /// Claude Code sends initial user prompts as plain strings like `"content": "ok"`,
    /// but tool results and complex messages use array format like
    /// `"content": [{"type": "text", "text": "..."}]`.
    #[serde(default, deserialize_with = "deserialize_message_content")]
    pub content: Option<MessageContent>,
}

/// Message content that can be either a plain string or an array of content blocks.
///
/// Claude Code uses different formats depending on the message type:
/// - Initial user prompts: `"content": "hello"` (plain string)
/// - Tool results/complex messages: `"content": [{"type": "text", "text": "..."}]` (array)
#[derive(Debug, Clone, PartialEq)]
pub enum MessageContent {
    /// Plain text content (used for simple user prompts)
    Text(String),
    /// Array of content blocks (used for tool results, assistant messages)
    Blocks(Vec<ContentBlock>),
}

impl MessageContent {
    /// Extract plain text from the content, if available.
    /// For `Text` variant, returns the string directly.
    /// For `Blocks` variant, finds the first text block and returns its content.
    pub fn text(&self) -> Option<&str> {
        match self {
            MessageContent::Text(s) => Some(s.as_str()),
            MessageContent::Blocks(blocks) => blocks.iter().find_map(|b| {
                if let ContentBlock::Text { text } = b {
                    Some(text.as_str())
                } else {
                    None
                }
            }),
        }
    }

    /// Get content blocks, if this is the Blocks variant.
    pub fn blocks(&self) -> Option<&[ContentBlock]> {
        match self {
            MessageContent::Text(_) => None,
            MessageContent::Blocks(blocks) => Some(blocks),
        }
    }
}

/// Custom deserializer for MessageContent that handles both string and array formats.
fn deserialize_message_content<'de, D>(deserializer: D) -> Result<Option<MessageContent>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::{self, Visitor};

    struct MessageContentVisitor;

    impl<'de> Visitor<'de> for MessageContentVisitor {
        type Value = Option<MessageContent>;

        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            formatter.write_str("a string, an array of content blocks, or null")
        }

        fn visit_none<E>(self) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(None)
        }

        fn visit_unit<E>(self) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(None)
        }

        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(MessageContent::Text(value.to_string())))
        }

        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(MessageContent::Text(value)))
        }

        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
        where
            A: de::SeqAccess<'de>,
        {
            let blocks: Vec<ContentBlock> =
                Deserialize::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
            Ok(Some(MessageContent::Blocks(blocks)))
        }
    }

    deserializer.deserialize_any(MessageContentVisitor)
}

/// Token usage statistics from API response.
#[derive(Debug, Clone, Deserialize)]
pub struct TokenUsage {
    pub input_tokens: Option<i64>,
    pub output_tokens: Option<i64>,

    #[serde(default)]
    pub cache_read_input_tokens: Option<i64>,

    #[serde(default)]
    pub cache_creation_input_tokens: Option<i64>,

    /// Detailed cache creation info
    #[serde(default)]
    pub cache_creation: Option<CacheCreationDetails>,
}

/// Detailed cache creation statistics.
#[derive(Debug, Clone, Deserialize)]
pub struct CacheCreationDetails {
    #[serde(default)]
    pub ephemeral_5m_input_tokens: Option<i64>,

    #[serde(default)]
    pub ephemeral_1h_input_tokens: Option<i64>,
}

/// Content block types in messages.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
    /// Text content
    Text { text: String },

    /// Tool use request
    ToolUse {
        id: String,
        name: String,
        #[serde(default)]
        input: serde_json::Value,
    },

    /// Tool result (in user messages)
    ToolResult {
        tool_use_id: String,
        #[serde(default)]
        content: serde_json::Value,
    },

    /// Thinking block (extended thinking)
    Thinking {
        #[serde(default)]
        thinking: String,
    },
}

impl TranscriptEntry {
    /// Convert this transcript entry to Event(s).
    ///
    /// A single entry may produce multiple events:
    /// - Assistant message with usage → ApiRequest
    /// - User message → UserPromptSubmit
    /// - Tool use blocks → PreToolUse events
    /// - Tool result blocks → PostToolUse events
    ///
    /// The `tool_info_map` is used to track tool_use_id → (tool_name, input) mappings
    /// across entries, so that PostToolUse events can include the tool name and command.
    pub fn into_events(self, machine_id: &str, tool_info_map: &mut ToolInfoMap) -> Vec<Event> {
        let mut events = Vec::new();

        // Parse timestamp
        let timestamp = chrono::DateTime::parse_from_rfc3339(&self.timestamp)
            .ok()
            .map(|dt| dt.with_timezone(&Utc));

        let base_builder = || {
            let mut b = EventBuilder::new(machine_id, EventType::ApiRequest, &self.session_id)
                .source("transcript")
                .framework("claude")
                .is_sidechain(self.is_sidechain);

            if let Some(ts) = timestamp {
                b = b.timestamp(ts);
            }
            if let Some(ref cwd) = self.cwd {
                b = b.cwd(cwd.clone());
            }
            if let Some(ref branch) = self.git_branch {
                b = b.git_branch(branch.clone());
            }

            // Store uuid and parent_uuid in metadata
            let mut metadata = serde_json::Map::new();
            metadata.insert("uuid".into(), serde_json::json!(&self.uuid));
            if let Some(ref parent) = self.parent_uuid {
                metadata.insert("parent_uuid".into(), serde_json::json!(parent));
            }
            b = b.metadata(serde_json::Value::Object(metadata).to_string());

            b
        };

        match self.entry_type.as_str() {
            "assistant" => {
                if let Some(ref message) = self.message {
                    // Create ApiRequest event if we have usage data
                    if let Some(ref usage) = message.usage
                        && (usage.input_tokens.is_some() || usage.output_tokens.is_some())
                    {
                        let mut builder = base_builder();

                        // Set tokens
                        let input = usage.input_tokens.unwrap_or(0);
                        let output = usage.output_tokens.unwrap_or(0);
                        builder = builder.tokens(input, output);

                        // Set cache tokens
                        if let Some(cache_read) = usage.cache_read_input_tokens {
                            builder = builder.tokens_cache_read_opt(Some(cache_read));
                        }
                        if let Some(cache_write) = usage.cache_creation_input_tokens {
                            builder = builder.tokens_cache_write_opt(Some(cache_write));
                        }

                        // Set model (skip synthetic placeholder used for errors/timeouts)
                        if let Some(ref model) = message.model
                            && model != "<synthetic>"
                        {
                            builder = builder.model(model.clone());
                        }

                        events.push(builder.build());
                    }

                    // Create PreToolUse events for tool_use blocks
                    if let Some(ref content) = message.content
                        && let Some(blocks) = content.blocks()
                    {
                        for block in blocks {
                            if let ContentBlock::ToolUse { id, name, input } = block {
                                // Register tool info for later PostToolUse events
                                tool_info_map.insert(
                                    id.clone(),
                                    ToolInfo {
                                        name: name.clone(),
                                        input: input.clone(),
                                    },
                                );

                                let builder = EventBuilder::new(
                                    machine_id,
                                    EventType::PreToolUse,
                                    &self.session_id,
                                )
                                .source("transcript")
                                .framework("claude")
                                .is_sidechain(self.is_sidechain)
                                .tool(id.clone(), name.clone())
                                .timestamp_opt(timestamp)
                                .cwd_opt(self.cwd.clone())
                                .git_branch_opt(self.git_branch.clone());

                                events.push(builder.build());
                            }
                        }
                    }
                }
            }

            "user" => {
                // Create UserPromptSubmit for user messages
                let message = self.message.as_ref();

                // Extract prompt text if available.
                // MessageContent handles both plain string format (e.g., "content": "ok")
                // and array format (e.g., "content": [{"type": "text", "text": "ok"}])
                let prompt_text = message
                    .and_then(|m| m.content.as_ref())
                    .and_then(|c| c.text())
                    .map(|s| s.to_string());

                // Only create UserPromptSubmit if there's text content
                // (tool_result entries don't need a UserPromptSubmit)
                if prompt_text.is_some() {
                    let mut builder = EventBuilder::new(
                        machine_id,
                        EventType::UserPromptSubmit,
                        &self.session_id,
                    )
                    .source("transcript")
                    .framework("claude")
                    .is_sidechain(self.is_sidechain)
                    .timestamp_opt(timestamp);

                    if let Some(ref cwd) = self.cwd {
                        builder = builder.cwd(cwd.clone());
                    }
                    if let Some(ref branch) = self.git_branch {
                        builder = builder.git_branch(branch.clone());
                    }

                    // Store prompt in payload (truncated if very long)
                    if let Some(text) = prompt_text {
                        let truncated = truncate_string(&text, 1000);
                        builder =
                            builder.payload(serde_json::json!({"prompt": truncated}).to_string());
                    }

                    events.push(builder.build());
                }

                // Create PostToolUse events for tool_result blocks
                if let Some(ref message) = self.message
                    && let Some(ref content) = message.content
                    && let Some(blocks) = content.blocks()
                {
                    for block in blocks {
                        if let ContentBlock::ToolResult {
                            tool_use_id,
                            content: result_content,
                        } = block
                        {
                            let mut builder = EventBuilder::new(
                                machine_id,
                                EventType::PostToolUse,
                                &self.session_id,
                            )
                            .source("transcript")
                            .framework("claude")
                            .is_sidechain(self.is_sidechain)
                            .tool_use_id(tool_use_id.clone())
                            .timestamp_opt(timestamp)
                            .cwd_opt(self.cwd.clone())
                            .git_branch_opt(self.git_branch.clone());

                            // Look up tool info from the map to enrich PostToolUse events
                            if let Some(tool_info) = tool_info_map.get(tool_use_id) {
                                // Set tool_name so GitHub context extraction can check for Bash
                                // (tool_use_id is already set above, so only set tool_name here)
                                builder = builder.tool_name(tool_info.name.clone());

                                // Only include command/output for Bash tools (needed for GitHub context extraction)
                                if tool_info.name == "Bash" {
                                    // Include command in payload
                                    if let Some(command) = tool_info.input.get("command") {
                                        builder = builder.payload(
                                            serde_json::json!({ "command": command }).to_string(),
                                        );
                                    }

                                    // Include output in metadata for context extraction
                                    builder = builder.metadata(
                                        serde_json::json!({ "output": result_content }).to_string(),
                                    );
                                }
                            }

                            events.push(builder.build());
                        }
                    }
                }
            }

            _ => {
                // Skip other entry types (file-history-snapshot, etc.)
            }
        }

        events
    }

    /// Get the unique identifier for deduplication.
    pub fn dedup_key(&self) -> &str {
        &self.uuid
    }

    /// Check if this entry should be skipped (non-conversation data).
    pub fn should_skip(&self) -> bool {
        // Skip file-history-snapshot and other non-message entries
        self.entry_type != "user" && self.entry_type != "assistant"
    }
}

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

    #[test]
    fn test_parse_assistant_with_usage() {
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-123",
            "parentUuid": "entry-122",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "cwd": "/projects/test",
            "message": {
                "role": "assistant",
                "model": "claude-opus-4-5-20251101",
                "usage": {
                    "input_tokens": 1000,
                    "output_tokens": 500,
                    "cache_read_input_tokens": 200,
                    "cache_creation_input_tokens": 100
                }
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        assert_eq!(entry.session_id, "test-session");
        assert_eq!(entry.uuid, "entry-123");
        assert!(!entry.is_sidechain);

        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);
        assert_eq!(events.len(), 1);

        let event = &events[0];
        assert_eq!(event.event_type, EventType::ApiRequest);
        assert_eq!(event.session_id, "test-session");
        assert_eq!(event.source, Some("transcript".to_string()));
        assert_eq!(event.is_sidechain, Some(false));
        assert_eq!(event.tokens_input, Some(1000));
        assert_eq!(event.tokens_output, Some(500));
        assert_eq!(event.tokens_cache_read, Some(200));
        assert_eq!(event.tokens_cache_write, Some(100));
        assert_eq!(event.model, Some("claude-opus-4-5-20251101".to_string()));
    }

    #[test]
    fn test_parse_sidechain_entry() {
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-456",
            "isSidechain": true,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "message": {
                "role": "assistant",
                "usage": {
                    "input_tokens": 100,
                    "output_tokens": 50
                }
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        assert!(entry.is_sidechain);

        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);
        assert_eq!(events[0].is_sidechain, Some(true));
    }

    #[test]
    fn test_synthetic_model_is_filtered() {
        // Claude Code uses "<synthetic>" as a placeholder for error messages,
        // rate limits, timeouts, and synthetic responses. These should not
        // be recorded as the actual model.
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-synthetic",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "message": {
                "role": "assistant",
                "model": "<synthetic>",
                "usage": {
                    "input_tokens": 0,
                    "output_tokens": 0
                },
                "content": [
                    {"type": "text", "text": "API Error: 529 overloaded"}
                ]
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::ApiRequest);
        // Model should be None, not "<synthetic>"
        assert_eq!(events[0].model, None);
    }

    #[test]
    fn test_parse_user_message() {
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-789",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Hello, Claude!"}
                ]
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::UserPromptSubmit);
    }

    #[test]
    fn test_parse_user_message_string_content() {
        // Claude Code sends initial user prompts with content as a plain string,
        // not an array of content blocks. This is the format that was previously
        // causing prompts to be silently dropped.
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-string",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {
                "role": "user",
                "content": "ok"
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();

        // Verify the content was parsed as MessageContent::Text
        let message = entry.message.as_ref().unwrap();
        let content = message.content.as_ref().unwrap();
        assert!(matches!(content, MessageContent::Text(_)));
        assert_eq!(content.text(), Some("ok"));
        assert!(content.blocks().is_none());

        // Verify event generation works correctly
        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::UserPromptSubmit);

        // Verify the prompt is in the payload
        let payload: serde_json::Value =
            serde_json::from_str(events[0].payload.as_ref().unwrap()).unwrap();
        assert_eq!(payload["prompt"], "ok");
    }

    #[test]
    fn test_message_content_blocks_variant() {
        // Test the Blocks variant of MessageContent
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-blocks",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Hello from blocks!"}
                ]
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        let message = entry.message.as_ref().unwrap();
        let content = message.content.as_ref().unwrap();

        // Verify it's the Blocks variant
        assert!(matches!(content, MessageContent::Blocks(_)));
        assert_eq!(content.text(), Some("Hello from blocks!"));
        assert!(content.blocks().is_some());
        assert_eq!(content.blocks().unwrap().len(), 1);
    }

    #[test]
    fn test_parse_tool_use() {
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-tool",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "message": {
                "role": "assistant",
                "usage": {
                    "input_tokens": 100,
                    "output_tokens": 50
                },
                "content": [
                    {"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {"command": "ls"}}
                ]
            }
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);

        // Should have ApiRequest + PreToolUse
        assert_eq!(events.len(), 2);
        assert_eq!(events[0].event_type, EventType::ApiRequest);
        assert_eq!(events[1].event_type, EventType::PreToolUse);
        assert_eq!(events[1].tool_use_id, Some("toolu_123".to_string()));
        assert_eq!(events[1].tool_name, Some("Bash".to_string()));

        // Verify tool info was registered
        assert!(tool_info_map.contains_key("toolu_123"));
        assert_eq!(tool_info_map["toolu_123"].name, "Bash");
    }

    #[test]
    fn test_parse_tool_result_with_enrichment() {
        // First, process a ToolUse entry to populate the tool_info_map
        let tool_use_json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-tool",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "message": {
                "role": "assistant",
                "usage": {
                    "input_tokens": 100,
                    "output_tokens": 50
                },
                "content": [
                    {"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {"command": "ls -la"}}
                ]
            }
        }"#;

        let tool_use_entry: TranscriptEntry = serde_json::from_str(tool_use_json).unwrap();
        let mut tool_info_map = ToolInfoMap::default();
        let _ = tool_use_entry.into_events("machine-1", &mut tool_info_map);

        // Now process the tool result entry
        let tool_result_json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-result",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {
                "role": "user",
                "content": [
                    {"type": "tool_result", "tool_use_id": "toolu_123", "content": "file1.txt\nfile2.txt"}
                ]
            }
        }"#;

        let result_entry: TranscriptEntry = serde_json::from_str(tool_result_json).unwrap();
        let events = result_entry.into_events("machine-1", &mut tool_info_map);

        // Should have PostToolUse only (no text content for UserPromptSubmit)
        assert_eq!(events.len(), 1);
        let event = &events[0];
        assert_eq!(event.event_type, EventType::PostToolUse);
        assert_eq!(event.tool_use_id, Some("toolu_123".to_string()));

        // Verify tool_name is now populated from the map
        assert_eq!(event.tool_name, Some("Bash".to_string()));

        // Verify payload contains command
        assert!(event.payload.is_some());
        let payload: serde_json::Value =
            serde_json::from_str(event.payload.as_ref().unwrap()).unwrap();
        assert_eq!(payload["command"], "ls -la");

        // Verify metadata contains output
        assert!(event.metadata.is_some());
        let metadata: serde_json::Value =
            serde_json::from_str(event.metadata.as_ref().unwrap()).unwrap();
        assert_eq!(metadata["output"], "file1.txt\nfile2.txt");
    }

    #[test]
    fn test_parse_non_bash_tool_no_payload() {
        // Non-Bash tools should get tool_name but NOT payload/metadata
        let tool_use_json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-tool",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "assistant",
            "message": {
                "role": "assistant",
                "usage": { "input_tokens": 100, "output_tokens": 50 },
                "content": [
                    {"type": "tool_use", "id": "toolu_read", "name": "Read", "input": {"file_path": "/test.txt"}}
                ]
            }
        }"#;

        let tool_use_entry: TranscriptEntry = serde_json::from_str(tool_use_json).unwrap();
        let mut tool_info_map = ToolInfoMap::default();
        let _ = tool_use_entry.into_events("machine-1", &mut tool_info_map);

        let tool_result_json = r#"{
            "sessionId": "test-session",
            "uuid": "entry-result",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {
                "role": "user",
                "content": [
                    {"type": "tool_result", "tool_use_id": "toolu_read", "content": "file contents here"}
                ]
            }
        }"#;

        let result_entry: TranscriptEntry = serde_json::from_str(tool_result_json).unwrap();
        let events = result_entry.into_events("machine-1", &mut tool_info_map);

        assert_eq!(events.len(), 1);
        let event = &events[0];

        // tool_name should be set for all tools
        assert_eq!(event.tool_name, Some("Read".to_string()));

        // But payload and metadata should NOT be set for non-Bash tools
        assert!(event.payload.is_none());
        assert!(event.metadata.is_none());
    }

    #[test]
    fn test_skip_non_message_entries() {
        let json = r#"{
            "sessionId": "test-session",
            "uuid": "snapshot-1",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "file-history-snapshot"
        }"#;

        let entry: TranscriptEntry = serde_json::from_str(json).unwrap();
        assert!(entry.should_skip());

        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);
        assert!(events.is_empty());
    }

    #[test]
    fn test_user_message_with_multibyte_utf8_truncation() {
        // Create a prompt with exactly 999 ASCII chars followed by multi-byte UTF-8
        // This tests the edge case where byte 1000 falls in the middle of a character
        let long_prompt = format!("{}{}", "a".repeat(999), "é"); // 999 + 2 bytes = 1001 bytes

        let json = format!(
            r#"{{
            "sessionId": "test-session",
            "uuid": "entry-utf8",
            "isSidechain": false,
            "timestamp": "2025-12-25T21:30:31.390Z",
            "type": "user",
            "message": {{
                "role": "user",
                "content": [
                    {{"type": "text", "text": "{}"}}
                ]
            }}
        }}"#,
            long_prompt
        );

        let entry: TranscriptEntry = serde_json::from_str(&json).unwrap();
        // This should NOT panic even though byte 1000 is in the middle of "é"
        let mut tool_info_map = ToolInfoMap::default();
        let events = entry.into_events("machine-1", &mut tool_info_map);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::UserPromptSubmit);

        // Verify payload contains truncated prompt ending at valid char boundary
        let payload: serde_json::Value =
            serde_json::from_str(events[0].payload.as_ref().unwrap()).unwrap();
        let prompt = payload["prompt"].as_str().unwrap();
        // Should be 999 'a's + "..." (truncated at byte 999 since byte 1000 is mid-character)
        assert!(prompt.ends_with("..."));
        assert_eq!(prompt.len(), 1002); // 999 + "..."
    }
}
