//! Facts consumer: converts a canonical event stream into a `ParsedSession`.
//!
//! This is the single consumer that replaces the duplicated fact-construction
//! logic previously embedded in `claude::parse_from_offset` and
//! `codex::parse_from_offset`.

use std::collections::{HashMap, HashSet};
use std::path::Path;

use crate::model::fact::FactType;
use crate::model::Fact;
use serde_json::Value;

use super::common::{
    is_git_command, parse_exit_code, resolve_started_at, tool_call_fact, truncate,
};
use super::event::{ContentBlock, EventKind, SessionEvent};
use super::types::{Engine, ParsedSession, SessionMeta};

const RESULT_EXIT_CODE_KEY: &str = "__gaal_explicit_exit_code";
const RESULT_SUCCESS_KEY: &str = "__gaal_explicit_success";

/// Intermediate state for a pending tool call awaiting its result.
#[derive(Debug, Clone)]
struct ToolCallState {
    tool_name: String,
    fact_index: Option<usize>,
    #[allow(dead_code)]
    subject: Option<String>,
    detail: Option<String>,
}

fn is_shell_tool(tool_name: &str) -> bool {
    tool_name.eq_ignore_ascii_case("bash") || tool_name.eq_ignore_ascii_case("exec_command")
}

fn is_non_error_tool(tool_name: &str) -> bool {
    [
        "read",
        "glob",
        "grep",
        "webfetch",
        "websearch",
        "write",
        "edit",
        "notebookedit",
    ]
    .iter()
    .any(|name| tool_name.eq_ignore_ascii_case(name))
}

fn explicit_result_status(input: Option<&Value>) -> (Option<i32>, Option<bool>) {
    let explicit_exit_code = input
        .and_then(|value| value.get(RESULT_EXIT_CODE_KEY))
        .and_then(Value::as_i64)
        .and_then(|code| i32::try_from(code).ok());
    let explicit_success = input
        .and_then(|value| value.get(RESULT_SUCCESS_KEY))
        .and_then(Value::as_bool);
    (explicit_exit_code, explicit_success)
}

fn command_success(
    exit_code: Option<i32>,
    explicit_success: Option<bool>,
    is_error: bool,
) -> Option<bool> {
    explicit_success
        .or_else(|| exit_code.map(|code| code == 0))
        .or_else(|| is_error.then_some(false))
}

/// Convert a canonical event stream into a `ParsedSession`.
///
/// Replicates all fact-construction behaviour previously spread across
/// `claude::parse_from_offset` and `codex::parse_from_offset`:
///
/// - Metadata extraction (session_id, model, cwd, version)
/// - Usage deduplication
/// - Turn counting on `UserMessage`
/// - Fact construction (user prompts, assistant replies, tool calls, errors)
/// - Exit-code backfill from `ToolResult` events
/// - Git-op detection
/// - Exit signal extraction
pub fn extract_parsed_session(
    events: &[SessionEvent],
    engine: Engine,
    path: &Path,
) -> ParsedSession {
    // -- Metadata accumulators --
    let mut session_id: Option<String> = None;
    let mut model: Option<String> = None;
    let mut cwd: Option<String> = None;
    let mut version: Option<String> = None;
    let mut forked_from_id: Option<String> = None;
    let mut agent_role: Option<String> = None;
    let mut agent_nickname: Option<String> = None;
    let mut started_at: Option<String> = None;
    let mut last_event_at: Option<String> = None;
    let mut last_stop_reason: Option<String> = None;
    let mut session_summary: Option<String> = None;

    // -- Counters --
    let mut total_input_tokens = 0i64;
    let mut total_output_tokens = 0i64;
    let mut cache_read_tokens = 0i64;
    let mut cache_creation_tokens = 0i64;
    let mut reasoning_tokens = 0i64;
    let mut peak_context = 0i64;
    let mut total_tools = 0i32;
    let mut total_turns = 0i32;
    let mut usage_keys_seen: HashSet<String> = HashSet::new();
    let mut error_keys_seen: HashSet<String> = HashSet::new();

    // -- Facts --
    let mut facts: Vec<Fact> = Vec::new();
    let mut tool_state_by_id: HashMap<String, ToolCallState> = HashMap::new();

    for event in events {
        // Track timestamps.
        if let Some(ts) = &event.timestamp {
            if started_at.is_none() {
                started_at = Some(ts.clone());
            }
            last_event_at = Some(ts.clone());
        }

        let ts_str = event.timestamp.clone().unwrap_or_default();

        match &event.kind {
            // ── Meta ──────────────────────────────────────────────
            EventKind::Meta {
                session_id: sid,
                model: m,
                cwd: c,
                version: v,
                forked_from_id: parent_id,
                agent_role: role,
                agent_nickname: nickname,
            } => {
                if session_id.is_none() {
                    session_id = sid.clone();
                }
                if model.is_none() {
                    model = m.clone();
                }
                if cwd.is_none() {
                    cwd = c.clone();
                }
                if version.is_none() {
                    version = v.clone();
                }
                if forked_from_id.is_none() {
                    forked_from_id = parent_id.clone();
                }
                if agent_role.is_none() {
                    agent_role = role.clone();
                }
                if agent_nickname.is_none() {
                    agent_nickname = nickname.clone();
                }
            }

            // ── Usage ─────────────────────────────────────────────
            EventKind::Usage {
                input_tokens,
                output_tokens,
                cache_read_input_tokens,
                cache_creation_input_tokens,
                reasoning_tokens: evt_reasoning,
                dedup_key,
            } => {
                let should_count = dedup_key
                    .as_ref()
                    .map(|key| usage_keys_seen.insert(key.clone()))
                    .unwrap_or(true);
                if should_count {
                    total_input_tokens += input_tokens;
                    total_output_tokens += output_tokens;
                    cache_read_tokens += cache_read_input_tokens;
                    cache_creation_tokens += cache_creation_input_tokens;
                    reasoning_tokens += evt_reasoning;
                    // Track peak context: full input for the turn (non-cached + cache_read + cache_creation).
                    let full_input =
                        input_tokens + cache_read_input_tokens + cache_creation_input_tokens;
                    if full_input > peak_context {
                        peak_context = full_input;
                    }
                }
            }

            // ── User message ──────────────────────────────────────
            EventKind::UserMessage { content } => {
                total_turns += 1;
                let turn_number = Some(total_turns);

                // Extract first text block as user prompt.
                let prompt_text = content.iter().find_map(|block| match block {
                    ContentBlock::Text(text) => {
                        let trimmed = text.trim();
                        (!trimmed.is_empty()).then(|| trimmed.to_string())
                    }
                    _ => None,
                });

                if let Some(prompt) = prompt_text {
                    facts.push(Fact {
                        id: None,
                        session_id: String::new(),
                        ts: ts_str.clone(),
                        turn_number,
                        fact_type: FactType::UserPrompt,
                        subject: None,
                        detail: Some(prompt),
                        exit_code: None,
                        success: None,
                    });
                }

                // Process tool results embedded in user messages (subagent JSONL format)
                // for exit-code backfill on pending tool-call facts.
                for block in content {
                    if let ContentBlock::ToolResult {
                        tool_use_id,
                        content: output_text,
                    } = block
                    {
                        let exit_code = parse_exit_code(output_text);
                        if let Some(state) = tool_state_by_id.get(tool_use_id) {
                            let tool_name = state.tool_name.as_str();
                            if is_shell_tool(tool_name) {
                                if let Some(fact_idx) = state.fact_index {
                                    if let Some(fact) = facts.get_mut(fact_idx) {
                                        fact.exit_code = exit_code;
                                        fact.success = exit_code.map(|code| code == 0);
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // ── Assistant message ─────────────────────────────────
            EventKind::AssistantMessage {
                content,
                model: msg_model,
                ..
            } => {
                let turn_number = if total_turns > 0 {
                    Some(total_turns)
                } else {
                    None
                };

                // Pick up model from assistant message if not yet known.
                if model.is_none() {
                    model = msg_model.clone();
                }

                // One AssistantReply fact per text block (matches old per-block behaviour).
                for block in content {
                    match block {
                        ContentBlock::Text(text) => {
                            let trimmed = text.trim();
                            if !trimmed.is_empty() {
                                facts.push(Fact {
                                    id: None,
                                    session_id: String::new(),
                                    ts: ts_str.clone(),
                                    turn_number,
                                    fact_type: FactType::AssistantReply,
                                    subject: None,
                                    detail: Some(trimmed.to_string()),
                                    exit_code: None,
                                    success: None,
                                });
                            }
                        }
                        ContentBlock::ToolUse(tool_use) => {
                            // Tool calls embedded in assistant messages (subagent JSONL format)
                            // must produce facts identical to standalone ToolUse events.
                            total_tools += 1;

                            let fact = tool_call_fact(
                                &tool_use.name,
                                &tool_use.input,
                                ts_str.clone(),
                                turn_number,
                            );

                            let mut state = ToolCallState {
                                tool_name: tool_use.name.clone(),
                                fact_index: None,
                                subject: None,
                                detail: None,
                            };

                            if let Some(mut call_fact) = fact {
                                // Git-op detection.
                                if matches!(&call_fact.fact_type, FactType::Command) {
                                    if let Some(cmd) = call_fact.detail.clone() {
                                        if is_git_command(&cmd) {
                                            facts.push(Fact {
                                                id: None,
                                                session_id: String::new(),
                                                ts: ts_str.clone(),
                                                turn_number,
                                                fact_type: FactType::GitOp,
                                                subject: Some(truncate(&cmd, 100)),
                                                detail: Some(cmd),
                                                exit_code: None,
                                                success: None,
                                            });
                                        }
                                    }
                                }

                                state.subject = call_fact.subject.clone();
                                state.detail = call_fact.detail.clone();
                                state.fact_index = Some(facts.len());
                                call_fact.session_id = String::new();
                                facts.push(call_fact);
                            }

                            if !tool_use.id.is_empty() {
                                tool_state_by_id.insert(tool_use.id.clone(), state);
                            }
                        }
                        _ => {}
                    }
                }
            }

            // ── Tool use ──────────────────────────────────────────
            EventKind::ToolUse(tool_use) => {
                total_tools += 1;
                let turn_number = if total_turns > 0 {
                    Some(total_turns)
                } else {
                    None
                };

                let fact =
                    tool_call_fact(&tool_use.name, &tool_use.input, ts_str.clone(), turn_number);

                let mut state = ToolCallState {
                    tool_name: tool_use.name.clone(),
                    fact_index: None,
                    subject: None,
                    detail: None,
                };

                if let Some(mut call_fact) = fact {
                    // Git-op detection.
                    if matches!(&call_fact.fact_type, FactType::Command) {
                        if let Some(cmd) = call_fact.detail.clone() {
                            if is_git_command(&cmd) {
                                facts.push(Fact {
                                    id: None,
                                    session_id: String::new(),
                                    ts: ts_str.clone(),
                                    turn_number,
                                    fact_type: FactType::GitOp,
                                    subject: Some(truncate(&cmd, 100)),
                                    detail: Some(cmd),
                                    exit_code: None,
                                    success: None,
                                });
                            }
                        }
                    }

                    state.subject = call_fact.subject.clone();
                    state.detail = call_fact.detail.clone();
                    state.fact_index = Some(facts.len());
                    call_fact.session_id = String::new();
                    facts.push(call_fact);
                }

                if !tool_use.id.is_empty() {
                    tool_state_by_id.insert(tool_use.id.clone(), state);
                }
            }

            // ── Tool result ───────────────────────────────────────
            EventKind::ToolResult {
                tool_use_id,
                content: output_text,
                is_error,
                tool_name: fallback_tool_name,
                tool_input: fallback_tool_input,
            } => {
                let turn_number = if total_turns > 0 {
                    Some(total_turns)
                } else {
                    None
                };
                let parsed_exit_code = parse_exit_code(output_text.as_deref().unwrap_or_default());
                let (explicit_exit_code, explicit_success) =
                    explicit_result_status(fallback_tool_input.as_ref());
                let exit_code = explicit_exit_code.or_else(|| {
                    if explicit_success.is_some() {
                        None
                    } else {
                        parsed_exit_code
                    }
                });
                let result_success = command_success(exit_code, explicit_success, *is_error);
                let state = tool_state_by_id.get(tool_use_id).cloned();
                let fallback_tool_fact = state.as_ref().is_none().then(|| {
                    fallback_tool_name
                        .as_deref()
                        .zip(fallback_tool_input.as_ref())
                        .and_then(|(name, input)| {
                            tool_call_fact(name, input, ts_str.clone(), turn_number)
                        })
                });
                let fallback_tool_fact = fallback_tool_fact.flatten();
                let tool_name = state
                    .as_ref()
                    .map(|s| s.tool_name.as_str())
                    .or(fallback_tool_name.as_deref())
                    .unwrap_or("");
                let is_shell = is_shell_tool(tool_name);
                let is_blocked_tool = is_non_error_tool(tool_name);
                let shell_non_zero_exit =
                    is_shell && exit_code.map(|code| code != 0).unwrap_or(false);
                let shell_explicit_failure = is_shell && result_success == Some(false);

                // Backfill exit_code on the matching tool-call fact.
                if let Some(state) = state.as_ref() {
                    if let Some(fact_idx) = state.fact_index {
                        if let Some(fact) = facts.get_mut(fact_idx) {
                            // Both Claude (Bash) and Codex (Bash, exec_command).
                            if is_shell {
                                fact.exit_code = exit_code;
                                fact.success = result_success;
                            }
                        }
                    }
                }

                // AF4: Error facts come only from explicit `is_error` or shell non-zero exits.
                // Certain non-shell tools are explicitly excluded from error classification.
                let should_create_error_fact = !is_blocked_tool
                    && (*is_error || shell_non_zero_exit || shell_explicit_failure);
                if should_create_error_fact {
                    let error_key = if !tool_use_id.is_empty() {
                        format!("tool:{tool_use_id}")
                    } else {
                        format!("ts:{ts_str}|exit:{:?}", exit_code)
                    };
                    if error_keys_seen.insert(error_key) {
                        facts.push(Fact {
                            id: None,
                            session_id: String::new(),
                            ts: ts_str.clone(),
                            turn_number,
                            fact_type: FactType::Error,
                            subject: state.as_ref().and_then(|s| s.subject.clone()).or_else(|| {
                                fallback_tool_fact
                                    .as_ref()
                                    .and_then(|fact| fact.subject.clone())
                            }),
                            detail: output_text
                                .clone()
                                .or_else(|| state.as_ref().and_then(|s| s.detail.clone()))
                                .or_else(|| {
                                    fallback_tool_fact
                                        .as_ref()
                                        .and_then(|fact| fact.detail.clone())
                                }),
                            exit_code,
                            success: Some(false),
                        });
                    }
                }
            }

            // ── Stop signal ───────────────────────────────────────
            EventKind::StopSignal { reason } => {
                last_stop_reason = Some(reason.clone());
            }

            EventKind::Summary { text } => {
                if session_summary.is_none() {
                    session_summary = Some(text.clone());
                }
            }

            // ── Events not relevant to facts ──────────────────────
            EventKind::SubagentProgress { .. } | EventKind::SubagentCompletion { .. } => {}
        }
    }

    // -- Resolve session ID --
    let fallback_id = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("unknown")
        .to_string();
    let resolved_id = session_id.unwrap_or(fallback_id);

    // Backfill session_id on all facts.
    for fact in &mut facts {
        fact.session_id = resolved_id.clone();
    }

    let resolved_start = resolve_started_at(started_at, last_event_at.clone());
    ParsedSession {
        meta: SessionMeta {
            id: resolved_id,
            engine,
            model,
            cwd,
            started_at: resolved_start,
            version,
            forked_from_id,
            agent_role,
            agent_nickname,
        },
        facts,
        total_input_tokens,
        total_output_tokens,
        cache_read_tokens,
        cache_creation_tokens,
        reasoning_tokens,
        peak_context,
        total_tools,
        total_turns,
        ended_at: last_event_at.clone(),
        exit_signal: last_stop_reason,
        last_event_at,
        session_summary,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser::event::{ContentBlock, EventKind, SessionEvent, ToolUseEvent};
    use serde_json::{json, Value};
    use std::path::Path;

    fn meta_event(sid: &str, model: &str, cwd: &str) -> SessionEvent {
        SessionEvent {
            timestamp: Some("2026-03-07T10:00:00Z".to_string()),
            kind: EventKind::Meta {
                session_id: Some(sid.to_string()),
                model: Some(model.to_string()),
                cwd: Some(cwd.to_string()),
                version: Some("1.0.0".to_string()),
                forked_from_id: None,
                agent_role: None,
                agent_nickname: None,
            },
        }
    }

    fn user_msg(ts: &str, text: &str) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::UserMessage {
                content: vec![ContentBlock::Text(text.to_string())],
            },
        }
    }

    fn assistant_msg(ts: &str, text: &str) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::AssistantMessage {
                content: vec![ContentBlock::Text(text.to_string())],
                model: None,
                stop_reason: None,
            },
        }
    }

    fn assistant_msg_with_tool_use(ts: &str, id: &str, name: &str, input: Value) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::AssistantMessage {
                content: vec![ContentBlock::ToolUse(ToolUseEvent {
                    id: id.to_string(),
                    name: name.to_string(),
                    input,
                })],
                model: None,
                stop_reason: None,
            },
        }
    }

    fn tool_use_event(ts: &str, id: &str, name: &str, input: Value) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::ToolUse(ToolUseEvent {
                id: id.to_string(),
                name: name.to_string(),
                input,
            }),
        }
    }

    fn tool_result_event(
        ts: &str,
        tool_use_id: &str,
        content: &str,
        is_error: bool,
    ) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::ToolResult {
                tool_use_id: tool_use_id.to_string(),
                content: Some(content.to_string()),
                is_error,
                tool_name: None,
                tool_input: None,
            },
        }
    }

    fn usage_event(ts: &str, input: i64, output: i64, key: &str) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::Usage {
                input_tokens: input,
                output_tokens: output,
                cache_read_input_tokens: 0,
                cache_creation_input_tokens: 0,
                reasoning_tokens: 0,
                dedup_key: Some(key.to_string()),
            },
        }
    }

    fn stop_event(ts: &str, reason: &str) -> SessionEvent {
        SessionEvent {
            timestamp: Some(ts.to_string()),
            kind: EventKind::StopSignal {
                reason: reason.to_string(),
            },
        }
    }

    #[test]
    fn empty_events_produce_minimal_session() {
        let result = extract_parsed_session(&[], Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.meta.id, "test");
        assert_eq!(result.facts.len(), 0);
        assert_eq!(result.total_turns, 0);
        assert_eq!(result.total_tools, 0);
    }

    #[test]
    fn metadata_extraction() {
        let events = vec![meta_event("abc123", "opus", "/home/user")];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.meta.id, "abc123");
        assert_eq!(result.meta.model, Some("opus".to_string()));
        assert_eq!(result.meta.cwd, Some("/home/user".to_string()));
        assert_eq!(result.meta.version, Some("1.0.0".to_string()));
    }

    #[test]
    fn first_meta_wins() {
        let events = vec![
            meta_event("first", "opus", "/a"),
            meta_event("second", "sonnet", "/b"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.meta.id, "first");
        assert_eq!(result.meta.model, Some("opus".to_string()));
    }

    #[test]
    fn fallback_session_id_from_path() {
        let events = vec![SessionEvent {
            timestamp: Some("2026-03-07T10:00:00Z".to_string()),
            kind: EventKind::Meta {
                session_id: None,
                model: None,
                cwd: None,
                version: None,
                forked_from_id: None,
                agent_role: None,
                agent_nickname: None,
            },
        }];
        let result =
            extract_parsed_session(&events, Engine::Claude, Path::new("/path/to/abc123.jsonl"));
        assert_eq!(result.meta.id, "abc123");
    }

    #[test]
    fn user_message_creates_turn_and_prompt_fact() {
        let events = vec![user_msg("2026-03-07T10:00:00Z", "Hello world")];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_turns, 1);
        assert_eq!(result.facts.len(), 1);
        assert_eq!(result.facts[0].fact_type.as_str(), "user_prompt");
        assert_eq!(result.facts[0].detail, Some("Hello world".to_string()));
    }

    #[test]
    fn empty_user_message_no_fact() {
        let events = vec![SessionEvent {
            timestamp: Some("2026-03-07T10:00:00Z".to_string()),
            kind: EventKind::UserMessage {
                content: vec![ContentBlock::Text("   ".to_string())],
            },
        }];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_turns, 1);
        assert_eq!(result.facts.len(), 0); // Whitespace-only should not create a fact
    }

    #[test]
    fn assistant_message_creates_reply_fact() {
        let events = vec![
            user_msg("2026-03-07T10:00:00Z", "question"),
            assistant_msg("2026-03-07T10:01:00Z", "answer"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let reply_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "assistant_reply")
            .collect();
        assert_eq!(reply_facts.len(), 1);
        assert_eq!(reply_facts[0].detail, Some("answer".to_string()));
    }

    #[test]
    fn assistant_reply_preserves_full_content() {
        let long_text = "x".repeat(5000);
        let events = vec![assistant_msg("2026-03-07T10:00:00Z", &long_text)];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let detail = result.facts[0].detail.as_ref().unwrap();
        assert_eq!(
            detail.len(),
            5000,
            "full assistant reply content should be preserved"
        );
    }

    #[test]
    fn tool_use_creates_fact_and_counts() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "Read",
            json!({"file_path": "/src/main.rs"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_tools, 1);
        let read_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "file_read")
            .collect();
        assert_eq!(read_facts.len(), 1);
        assert_eq!(read_facts[0].subject, Some("/src/main.rs".to_string()));
    }

    #[test]
    fn inline_assistant_tool_use_counts_toward_total_tools() {
        let events = vec![assistant_msg_with_tool_use(
            "2026-03-07T10:00:00Z",
            "toolu_1",
            "Bash",
            json!({"command": "ls -la"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_tools, 1);
    }

    #[test]
    fn bash_tool_creates_command_fact() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "Bash",
            json!({"command": "ls -la"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let cmd_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "command")
            .collect();
        assert_eq!(cmd_facts.len(), 1);
        assert_eq!(cmd_facts[0].detail, Some("ls -la".to_string()));
    }

    #[test]
    fn git_command_creates_git_op_fact() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "Bash",
            json!({"command": "git commit -m 'test'"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let git_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "git_op")
            .collect();
        assert_eq!(git_facts.len(), 1);
    }

    #[test]
    fn write_tool_creates_file_write_fact() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "Write",
            json!({"file_path": "/src/new.rs", "content": "fn main() {}"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let write_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "file_write")
            .collect();
        assert_eq!(write_facts.len(), 1);
        assert_eq!(write_facts[0].subject, Some("/src/new.rs".to_string()));
    }

    #[test]
    fn tool_result_backfills_exit_code_on_bash() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "echo hi"}),
            ),
            tool_result_event(
                "2026-03-07T10:01:00Z",
                "call_1",
                "hi\nProcess exited with code 0",
                false,
            ),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, Some(0));
        assert_eq!(cmd_fact.success, Some(true));
    }

    #[test]
    fn tool_result_backfills_exit_code_on_exec_command() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "exec_command",
                json!({"command": "ls"}),
            ),
            tool_result_event("2026-03-07T10:01:00Z", "call_1", "Exit code 1", false),
        ];
        let result = extract_parsed_session(&events, Engine::Codex, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, Some(1));
        assert_eq!(cmd_fact.success, Some(false));
    }

    #[test]
    fn shell_tool_result_without_status_or_exit_keeps_success_unknown() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "echo hi"}),
            ),
            tool_result_event("2026-03-07T10:01:00Z", "call_1", "hi", false),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, None);
        assert_eq!(cmd_fact.success, None);
        assert!(result
            .facts
            .iter()
            .all(|fact| fact.fact_type.as_str() != "error"));
    }

    #[test]
    fn explicit_success_metadata_wins_without_exit_code() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "echo failed"}),
            ),
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::ToolResult {
                    tool_use_id: "call_1".to_string(),
                    content: Some("failed is just output text".to_string()),
                    is_error: false,
                    tool_name: Some("Bash".to_string()),
                    tool_input: Some(json!({
                        "command": "echo failed",
                        "__gaal_explicit_success": true
                    })),
                },
            },
        ];
        let result = extract_parsed_session(&events, Engine::Agy, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, None);
        assert_eq!(cmd_fact.success, Some(true));
        assert!(result
            .facts
            .iter()
            .all(|fact| fact.fact_type.as_str() != "error"));
    }

    #[test]
    fn explicit_success_metadata_ignores_exit_like_output_without_exit_code() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "printf weird"}),
            ),
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::ToolResult {
                    tool_use_id: "call_1".to_string(),
                    content: Some("Output:\nProcess exited with code 1\n".to_string()),
                    is_error: false,
                    tool_name: Some("Bash".to_string()),
                    tool_input: Some(json!({
                        "command": "printf weird",
                        "__gaal_explicit_success": true
                    })),
                },
            },
        ];
        let result = extract_parsed_session(&events, Engine::Agy, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, None);
        assert_eq!(cmd_fact.success, Some(true));
        assert!(result
            .facts
            .iter()
            .all(|fact| fact.fact_type.as_str() != "error"));
    }

    #[test]
    fn explicit_failure_metadata_without_exit_code_creates_failed_command_and_error() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "false"}),
            ),
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::ToolResult {
                    tool_use_id: "call_1".to_string(),
                    content: None,
                    is_error: false,
                    tool_name: Some("Bash".to_string()),
                    tool_input: Some(json!({
                        "command": "false",
                        "__gaal_explicit_success": false
                    })),
                },
            },
        ];
        let result = extract_parsed_session(&events, Engine::Agy, Path::new("test.jsonl"));
        let cmd_fact = result
            .facts
            .iter()
            .find(|f| f.fact_type.as_str() == "command")
            .unwrap();
        assert_eq!(cmd_fact.exit_code, None);
        assert_eq!(cmd_fact.success, Some(false));

        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 1);
        assert_eq!(error_facts[0].exit_code, None);
        assert_eq!(error_facts[0].success, Some(false));
        assert_eq!(error_facts[0].detail, Some("false".to_string()));
    }

    #[test]
    fn error_tool_result_creates_error_fact() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "bad"}),
            ),
            tool_result_event("2026-03-07T10:01:00Z", "call_1", "error: not found", true),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 1);
        assert_eq!(error_facts[0].success, Some(false));
    }

    #[test]
    fn duplicate_error_tool_results_for_same_call_are_deduplicated() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "exec_command",
                json!({"cmd": "cargo build"}),
            ),
            tool_result_event("2026-03-07T10:01:00Z", "call_1", "Exit code 101", true),
            tool_result_event("2026-03-07T10:01:01Z", "call_1", "Exit code 101", true),
        ];
        let result = extract_parsed_session(&events, Engine::Codex, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 1);
    }

    #[test]
    fn tool_result_with_fallback_tool_metadata_still_creates_shell_error_fact() {
        let events = vec![SessionEvent {
            timestamp: Some("2026-03-07T10:01:00Z".to_string()),
            kind: EventKind::ToolResult {
                tool_use_id: "call_1".to_string(),
                content: Some("Command failed\nProcess exited with code 1".to_string()),
                is_error: false,
                tool_name: Some("exec_command".to_string()),
                tool_input: Some(json!({"cmd": "cargo build"})),
            },
        }];
        let result = extract_parsed_session(&events, Engine::Codex, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 1);
        assert_eq!(error_facts[0].subject, Some("cargo build".to_string()));
        assert_eq!(error_facts[0].exit_code, Some(1));
    }

    #[test]
    fn output_containing_error_creates_error_fact() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Bash",
                json!({"command": "compile"}),
            ),
            tool_result_event(
                "2026-03-07T10:01:00Z",
                "call_1",
                "compilation failed with error\nProcess exited with code 1",
                false,
            ),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 1);
    }

    #[test]
    fn non_shell_error_text_does_not_create_error_fact() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "Read",
                json!({"file_path": "/tmp/data.txt"}),
            ),
            tool_result_event(
                "2026-03-07T10:01:00Z",
                "call_1",
                "The file contains the phrase: failed to compile",
                false,
            ),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 0);
    }

    #[test]
    fn excluded_tool_never_creates_error_fact_even_with_is_error_true() {
        let events = vec![
            tool_use_event(
                "2026-03-07T10:00:00Z",
                "call_1",
                "WebSearch",
                json!({"query": "rust error handling"}),
            ),
            tool_result_event("2026-03-07T10:01:00Z", "call_1", "request failed", true),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let error_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "error")
            .collect();
        assert_eq!(error_facts.len(), 0);
    }

    #[test]
    fn usage_deduplication() {
        let events = vec![
            usage_event("2026-03-07T10:00:00Z", 100, 200, "msg_1"),
            usage_event("2026-03-07T10:01:00Z", 100, 200, "msg_1"), // duplicate
            usage_event("2026-03-07T10:02:00Z", 50, 100, "msg_2"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_input_tokens, 150); // 100 + 50, not 250
        assert_eq!(result.total_output_tokens, 300); // 200 + 100, not 500
    }

    #[test]
    fn usage_without_dedup_key_always_counted() {
        let events = vec![
            SessionEvent {
                timestamp: Some("2026-03-07T10:00:00Z".to_string()),
                kind: EventKind::Usage {
                    input_tokens: 100,
                    output_tokens: 200,
                    cache_read_input_tokens: 0,
                    cache_creation_input_tokens: 0,
                    reasoning_tokens: 0,
                    dedup_key: None,
                },
            },
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::Usage {
                    input_tokens: 50,
                    output_tokens: 100,
                    cache_read_input_tokens: 0,
                    cache_creation_input_tokens: 0,
                    reasoning_tokens: 0,
                    dedup_key: None,
                },
            },
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_input_tokens, 150);
        assert_eq!(result.total_output_tokens, 300);
    }

    #[test]
    fn stop_signal_captured() {
        let events = vec![stop_event("2026-03-07T10:00:00Z", "end_turn")];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.exit_signal, Some("end_turn".to_string()));
    }

    #[test]
    fn last_stop_signal_wins() {
        let events = vec![
            stop_event("2026-03-07T10:00:00Z", "first"),
            stop_event("2026-03-07T10:01:00Z", "last"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.exit_signal, Some("last".to_string()));
    }

    #[test]
    fn timestamps_tracked_correctly() {
        let events = vec![
            user_msg("2026-03-07T10:00:00Z", "first"),
            assistant_msg("2026-03-07T10:30:00Z", "reply"),
            user_msg("2026-03-07T11:00:00Z", "second"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.meta.started_at, "2026-03-07T10:00:00Z");
        assert_eq!(
            result.last_event_at,
            Some("2026-03-07T11:00:00Z".to_string())
        );
        assert_eq!(result.ended_at, Some("2026-03-07T11:00:00Z".to_string()));
    }

    #[test]
    fn session_id_backfilled_on_all_facts() {
        let events = vec![
            meta_event("session_xyz", "opus", "/home"),
            user_msg("2026-03-07T10:00:00Z", "hello"),
            assistant_msg("2026-03-07T10:01:00Z", "world"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        for fact in &result.facts {
            assert_eq!(fact.session_id, "session_xyz");
        }
    }

    #[test]
    fn turn_numbers_assigned_correctly() {
        let events = vec![
            user_msg("2026-03-07T10:00:00Z", "turn 1"),
            assistant_msg("2026-03-07T10:01:00Z", "reply 1"),
            user_msg("2026-03-07T10:02:00Z", "turn 2"),
            assistant_msg("2026-03-07T10:03:00Z", "reply 2"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_turns, 2);

        let prompts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "user_prompt")
            .collect();
        assert_eq!(prompts[0].turn_number, Some(1));
        assert_eq!(prompts[1].turn_number, Some(2));
    }

    #[test]
    fn task_tool_creates_task_spawn_fact() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "Task",
            json!({"prompt": "do something", "description": "a task"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let task_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "task_spawn")
            .collect();
        assert_eq!(task_facts.len(), 1);
    }

    #[test]
    fn engine_preserved() {
        let result = extract_parsed_session(&[], Engine::Codex, Path::new("test.jsonl"));
        assert!(matches!(result.meta.engine, Engine::Codex));

        let result2 = extract_parsed_session(&[], Engine::Claude, Path::new("test.jsonl"));
        assert!(matches!(result2.meta.engine, Engine::Claude));
    }

    #[test]
    fn full_conversation_pipeline() {
        let events = vec![
            meta_event("sess_1", "claude-opus-4", "/project"),
            usage_event("2026-03-07T10:00:00Z", 500, 1000, "msg_1"),
            user_msg("2026-03-07T10:00:00Z", "Build a web server"),
            assistant_msg("2026-03-07T10:01:00Z", "I'll create a web server for you."),
            tool_use_event(
                "2026-03-07T10:02:00Z",
                "call_1",
                "Write",
                json!({"file_path": "/src/main.rs", "content": "fn main() {}"}),
            ),
            tool_result_event("2026-03-07T10:02:00Z", "call_1", "File written", false),
            tool_use_event(
                "2026-03-07T10:03:00Z",
                "call_2",
                "Bash",
                json!({"command": "cargo build"}),
            ),
            tool_result_event(
                "2026-03-07T10:03:00Z",
                "call_2",
                "Compiling...\nProcess exited with code 0",
                false,
            ),
            tool_use_event(
                "2026-03-07T10:04:00Z",
                "call_3",
                "Bash",
                json!({"command": "git commit -m 'init'"}),
            ),
            tool_result_event(
                "2026-03-07T10:04:00Z",
                "call_3",
                "Process exited with code 0",
                false,
            ),
            stop_event("2026-03-07T10:05:00Z", "end_turn"),
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));

        assert_eq!(result.meta.id, "sess_1");
        assert_eq!(result.total_turns, 1);
        assert_eq!(result.total_tools, 3);
        assert_eq!(result.total_input_tokens, 500);
        assert_eq!(result.total_output_tokens, 1000);
        assert_eq!(result.exit_signal, Some("end_turn".to_string()));

        // Should have: user_prompt, assistant_reply, file_write, command, git_op, command
        assert!(result
            .facts
            .iter()
            .any(|f| f.fact_type.as_str() == "user_prompt"));
        assert!(result
            .facts
            .iter()
            .any(|f| f.fact_type.as_str() == "assistant_reply"));
        assert!(result
            .facts
            .iter()
            .any(|f| f.fact_type.as_str() == "file_write"));
        assert!(result
            .facts
            .iter()
            .any(|f| f.fact_type.as_str() == "git_op"));
        assert!(result
            .facts
            .iter()
            .any(|f| f.fact_type.as_str() == "command"));
    }

    #[test]
    fn subagent_events_ignored_for_facts() {
        let events = vec![SessionEvent {
            timestamp: Some("2026-03-07T10:00:00Z".to_string()),
            kind: EventKind::SubagentProgress {
                agent_id: "agent_1".to_string(),
                prompt: "do stuff".to_string(),
                message: None,
                timestamp: None,
                total_tokens: Some(1000),
                total_duration_ms: Some(5000),
                total_tool_use_count: Some(3),
            },
        }];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.facts.len(), 0);
    }

    #[test]
    fn summary_events_ignored_for_facts() {
        let events = vec![SessionEvent {
            timestamp: Some("2026-03-07T10:00:00Z".to_string()),
            kind: EventKind::Summary {
                text: "Session summary here".to_string(),
            },
        }];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.facts.len(), 0);
    }

    #[test]
    fn no_read_fact_for_unrecognized_tool() {
        let events = vec![tool_use_event(
            "2026-03-07T10:00:00Z",
            "call_1",
            "UnknownTool",
            json!({"arg": "value"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_tools, 1); // Still counted
        assert_eq!(result.facts.len(), 0); // But no fact created for unknown tools
    }

    // ── Subagent JSONL embedded-tool-use tests ─────────────────────────────
    // Subagent JSONL files use the format:
    //   { "type": "assistant", "message": { "content": [{"type":"tool_use",...}] } }
    //   { "type": "user", "message": { "content": [{"type":"tool_result",...}] } }
    // i.e., tool calls are embedded as ContentBlock::ToolUse inside AssistantMessage,
    // not emitted as standalone EventKind::ToolUse events.

    #[test]
    fn inline_assistant_read_creates_file_read_fact() {
        let events = vec![assistant_msg_with_tool_use(
            "2026-03-07T10:00:00Z",
            "toolu_1",
            "Read",
            json!({"file_path": "/src/render/session_md.rs"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert_eq!(result.total_tools, 1);
        let read_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "file_read")
            .collect();
        assert_eq!(
            read_facts.len(),
            1,
            "inline Read must produce a file_read fact"
        );
        assert_eq!(
            read_facts[0].subject,
            Some("/src/render/session_md.rs".to_string())
        );
    }

    #[test]
    fn inline_assistant_write_creates_file_write_fact() {
        let events = vec![assistant_msg_with_tool_use(
            "2026-03-07T10:00:00Z",
            "toolu_2",
            "Write",
            json!({"file_path": "/src/output.rs", "content": "..."}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let write_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "file_write")
            .collect();
        assert_eq!(
            write_facts.len(),
            1,
            "inline Write must produce a file_write fact"
        );
        assert_eq!(write_facts[0].subject, Some("/src/output.rs".to_string()));
    }

    #[test]
    fn inline_assistant_bash_creates_command_fact() {
        let events = vec![assistant_msg_with_tool_use(
            "2026-03-07T10:00:00Z",
            "toolu_3",
            "Bash",
            json!({"command": "cargo test"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let cmd_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "command")
            .collect();
        assert_eq!(
            cmd_facts.len(),
            1,
            "inline Bash must produce a command fact"
        );
        assert_eq!(cmd_facts[0].detail, Some("cargo test".to_string()));
    }

    #[test]
    fn inline_assistant_bash_exit_code_backfilled_from_user_tool_result() {
        // Simulates subagent JSONL: assistant message with Bash tool use,
        // followed by user message with tool_result containing exit code.
        let events = vec![
            user_msg("2026-03-07T10:00:00Z", "run tests"),
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::AssistantMessage {
                    content: vec![ContentBlock::ToolUse(ToolUseEvent {
                        id: "toolu_bash".to_string(),
                        name: "Bash".to_string(),
                        input: json!({"command": "cargo test"}),
                    })],
                    model: None,
                    stop_reason: None,
                },
            },
            SessionEvent {
                timestamp: Some("2026-03-07T10:01:00Z".to_string()),
                kind: EventKind::UserMessage {
                    content: vec![ContentBlock::ToolResult {
                        tool_use_id: "toolu_bash".to_string(),
                        content: "Process exited with code 0".to_string(),
                    }],
                },
            },
        ];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        let cmd_facts: Vec<_> = result
            .facts
            .iter()
            .filter(|f| f.fact_type.as_str() == "command")
            .collect();
        assert_eq!(cmd_facts.len(), 1);
        assert_eq!(cmd_facts[0].exit_code, Some(0));
        assert_eq!(cmd_facts[0].success, Some(true));
    }

    #[test]
    fn inline_assistant_git_bash_creates_git_op_fact() {
        let events = vec![assistant_msg_with_tool_use(
            "2026-03-07T10:00:00Z",
            "toolu_git",
            "Bash",
            json!({"command": "git commit -m 'fix: bug'"}),
        )];
        let result = extract_parsed_session(&events, Engine::Claude, Path::new("test.jsonl"));
        assert!(
            result
                .facts
                .iter()
                .any(|f| f.fact_type.as_str() == "git_op"),
            "inline git Bash must produce a git_op fact"
        );
    }
}
