use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::remote::containers;

/// A Claude Code session discovered on disk.
#[derive(Debug, Serialize, Clone)]
pub struct SessionInfo {
    pub id: String,
    pub project: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub task: Option<String>,
    pub branch: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree: Option<String>,
    pub last_event: Option<String>,
    pub event_count: usize,
}

/// A parsed event from a Claude Code session JSONL file.
#[derive(Debug, Serialize, Clone)]
pub struct SessionEvent {
    #[serde(rename = "type")]
    pub event_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uuid: Option<String>,
}

/// Raw JSONL line — we deserialize leniently with defaults.
#[derive(Deserialize)]
struct RawEvent {
    #[serde(rename = "type", default)]
    event_type: Option<String>,
    #[serde(default)]
    timestamp: Option<String>,
    #[serde(default)]
    uuid: Option<String>,
    #[serde(default)]
    message: Option<RawMessage>,
    #[allow(dead_code)]
    #[serde(rename = "sessionId", default)]
    session_id: Option<String>,
    #[serde(rename = "gitBranch", default)]
    git_branch: Option<String>,
    #[allow(dead_code)]
    #[serde(default)]
    content: Option<String>,
    #[serde(default)]
    cwd: Option<String>,
}

#[derive(Deserialize)]
struct RawMessage {
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    model: Option<String>,
    #[serde(default)]
    content: Option<serde_json::Value>,
}

/// Discover Claude Code sessions in the app container.
/// Sessions live at ~/.claude/projects/*/  with *.jsonl files.
/// Excludes subagent sessions (under /subagents/ directories).
pub fn discover_sessions(app_container: &str) -> Result<Vec<SessionInfo>> {
    // Search only ~/.claude/projects/ directories (not the entire filesystem).
    // For each session file: print path, line count, first 5 lines (for branch),
    // and last 3 lines (for last_event timestamp).
    let output = containers::exec_in(
        app_container,
        &[
            "sh",
            "-c",
            r#"for d in /home/*/.claude/projects /root/.claude/projects; do
  [ -d "$d" ] || continue
  find "$d" -maxdepth 2 -name '*.jsonl' -not -path '*/subagents/*' -type f 2>/dev/null
done | while read -r f; do
  wc=$(wc -l < "$f" 2>/dev/null || echo 0)
  head=$(head -5 "$f" 2>/dev/null)
  tail=$(tail -3 "$f" 2>/dev/null)
  printf '@@KAP_SESSION@@%s@@%s\n%s\n@@KAP_TAIL@@\n%s\n' "$f" "$wc" "$head" "$tail"
done"#,
        ],
    )
    .unwrap_or_default();

    let mut sessions = Vec::new();

    for block in output.split("@@KAP_SESSION@@") {
        let block = block.trim();
        if block.is_empty() {
            continue;
        }

        // Format: {path}@@{line_count}\n{head lines}\n@@KAP_TAIL@@\n{tail lines}
        let (header, rest) = block.split_once('\n').unwrap_or((block, ""));
        let (path, wc_str) = header.rsplit_once("@@").unwrap_or((header, "0"));
        let event_count = wc_str.trim().parse::<usize>().unwrap_or(0);

        let (head, tail) = rest.split_once("@@KAP_TAIL@@").unwrap_or((rest, ""));

        let filename = path.rsplit('/').next().unwrap_or("");
        let session_id = filename.strip_suffix(".jsonl").unwrap_or(filename);
        let project_hash = extract_project_name(path);
        let project = decode_project_name(&project_hash);

        let mut branch = None;
        let mut task = None;
        let mut cwd = None;
        for line in head.lines() {
            if let Ok(evt) = serde_json::from_str::<RawEvent>(line) {
                if branch.is_none() {
                    branch = evt.git_branch;
                }
                if cwd.is_none() {
                    cwd = evt.cwd;
                }
                // Extract first user message as the session task/title
                if task.is_none()
                    && evt.event_type.as_deref() == Some("user")
                    && let Some(msg) = &evt.message
                    && msg.role.as_deref() == Some("user")
                {
                    task = extract_user_text(msg).map(|s| truncate_str(&s, 80));
                }
            }
        }
        let worktree = extract_worktree_name(cwd.as_deref());

        // Get last timestamp from the tail of the file
        let mut last_ts = None;
        for line in tail.lines().rev() {
            if let Ok(evt) = serde_json::from_str::<RawEvent>(line)
                && evt.timestamp.is_some()
            {
                last_ts = evt.timestamp;
                break;
            }
        }

        sessions.push(SessionInfo {
            id: session_id.to_string(),
            project,
            task,
            branch,
            cwd,
            worktree,
            last_event: last_ts,
            event_count,
        });
    }

    // If all projects are "workspace" (common in devcontainers), try to
    // resolve the actual repo name from git.
    let all_workspace = !sessions.is_empty() && sessions.iter().all(|s| s.project == "workspace");
    if all_workspace
        && let Some(repo_name) = containers::exec_in(
            app_container,
            &[
                "sh",
                "-c",
                "basename $(git -C /workspace rev-parse --show-toplevel 2>/dev/null) 2>/dev/null || basename $(pwd)",
            ],
        )
    {
        let name = repo_name.trim().to_string();
        if !name.is_empty() && name != "workspace" {
            for s in &mut sessions {
                s.project = name.clone();
            }
        }
    }

    // Sort by last_event descending (newest first)
    sessions.sort_by(|a, b| b.last_event.cmp(&a.last_event));

    Ok(sessions)
}

/// Read and parse a session's JSONL into structured events.
/// Filters out noise (progress events, meta messages) for a clean timeline.
pub fn read_session(app_container: &str, session_id: &str) -> Result<Vec<SessionEvent>> {
    // Find the session file
    let path = find_session_path(app_container, session_id)?;

    let raw = containers::exec_in(app_container, &["cat", &path])
        .ok_or_else(|| anyhow::anyhow!("failed to read session file"))?;

    Ok(parse_session_events(&raw))
}

/// Parse JSONL content into structured session events.
pub fn parse_session_events(jsonl: &str) -> Vec<SessionEvent> {
    jsonl
        .lines()
        .filter_map(|line| {
            let raw: RawEvent = serde_json::from_str(line).ok()?;
            let event_type = raw.event_type.as_deref()?;

            // Skip noise: internal bookkeeping events
            match event_type {
                "progress" | "file-history-snapshot" | "last-prompt" | "queue-operation" => {
                    return None;
                }
                _ => {}
            }

            let msg = raw.message.as_ref();
            let role = msg.and_then(|m| m.role.clone());

            // Skip meta user messages (internal commands, etc.)
            if event_type == "user"
                && role.as_deref() == Some("user")
                && let Some(m) = msg
                && let Some(serde_json::Value::String(s)) = &m.content
                && (s.starts_with("<local-command") || s.starts_with("<command-"))
            {
                return None;
            }

            let summary = extract_summary(event_type, msg);
            let model = msg.and_then(|m| m.model.clone());
            let tool_name = extract_tool_name(event_type, msg);

            Some(SessionEvent {
                event_type: event_type.to_string(),
                role,
                timestamp: raw.timestamp,
                summary,
                model,
                tool_name,
                uuid: raw.uuid,
            })
        })
        .collect()
}

/// Truncate a string: take first line, cap at `max_len` chars.
fn truncate_str(s: &str, max_len: usize) -> String {
    let first_line = s.lines().next().unwrap_or(s).trim();
    let is_multiline = s.contains('\n');
    if first_line.chars().count() <= max_len && !is_multiline {
        return first_line.to_string();
    }
    let truncated: String = first_line.chars().take(max_len).collect();
    format!("{truncated}…")
}

/// Extract a compact description from a tool_use input object.
fn compact_tool_input(tool_name: &str, input: &serde_json::Value) -> Option<String> {
    let obj = input.as_object()?;
    let raw = match tool_name {
        "Bash" => obj.get("command")?.as_str()?,
        "Read" | "Write" | "Edit" => obj.get("file_path")?.as_str()?,
        "Grep" | "Glob" => obj.get("pattern")?.as_str()?,
        "Agent" => {
            return obj
                .get("description")
                .or_else(|| obj.get("prompt"))
                .and_then(|v| v.as_str())
                .map(|s| truncate_str(s, 120));
        }
        _ => {
            return obj
                .get("command")
                .or_else(|| obj.get("file_path"))
                .or_else(|| obj.get("path"))
                .or_else(|| obj.get("query"))
                .and_then(|v| v.as_str())
                .map(|s| truncate_str(s, 120));
        }
    };
    Some(truncate_str(raw, 120))
}

/// Extract a summary from a tool_result content block.
fn tool_result_summary(item: &serde_json::Value) -> String {
    if item.get("is_error").and_then(|v| v.as_bool()) == Some(true) {
        let err = item
            .get("content")
            .and_then(|c| c.as_str())
            .unwrap_or("error");
        return format!("(error) {}", truncate_str(err, 150));
    }
    match item.get("content") {
        Some(serde_json::Value::String(s)) => {
            let trimmed = s.trim();
            if trimmed.is_empty() {
                "(empty result)".to_string()
            } else {
                truncate_str(trimmed, 200)
            }
        }
        Some(serde_json::Value::Array(arr)) => {
            for block in arr {
                if block.get("type").and_then(|t| t.as_str()) == Some("text")
                    && let Some(text) = block.get("text").and_then(|t| t.as_str())
                {
                    let trimmed = text.trim();
                    if !trimmed.is_empty() {
                        return truncate_str(trimmed, 200);
                    }
                }
            }
            "(tool result)".to_string()
        }
        _ => "(tool result)".to_string(),
    }
}

/// Extract a human-readable summary from the event.
fn extract_summary(event_type: &str, msg: Option<&RawMessage>) -> Option<String> {
    if event_type == "system" {
        return Some("system event".to_string());
    }

    let msg = msg?;

    match event_type {
        "user" => {
            // User message: extract text content or tool_result
            match &msg.content {
                Some(serde_json::Value::String(s)) => Some(truncate_str(s, 200)),
                Some(serde_json::Value::Array(arr)) => {
                    for item in arr {
                        let item_type = item.get("type").and_then(|t| t.as_str());
                        match item_type {
                            Some("text") => {
                                if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
                                    let trimmed = text.trim();
                                    if !trimmed.is_empty() {
                                        return Some(truncate_str(trimmed, 200));
                                    }
                                }
                            }
                            Some("tool_result") => {
                                return Some(tool_result_summary(item));
                            }
                            _ => {}
                        }
                    }
                    None
                }
                _ => None,
            }
        }
        "assistant" => {
            // Assistant message: extract text or tool_use type
            match &msg.content {
                Some(serde_json::Value::Array(arr)) => {
                    for item in arr {
                        let item_type = item.get("type").and_then(|t| t.as_str());
                        match item_type {
                            Some("text") => {
                                if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
                                    let trimmed = text.trim();
                                    if !trimmed.is_empty() {
                                        return Some(truncate_str(trimmed, 200));
                                    }
                                }
                            }
                            Some("tool_use") => {
                                let name = item.get("name").and_then(|n| n.as_str());
                                if let Some(name) = name {
                                    let desc = item
                                        .get("input")
                                        .and_then(|inp| compact_tool_input(name, inp));
                                    return match desc {
                                        Some(d) => Some(d),
                                        None => Some(format!("tool_use: {name}")),
                                    };
                                }
                            }
                            Some("thinking") => {
                                return Some("thinking…".to_string());
                            }
                            _ => {}
                        }
                    }
                    None
                }
                _ => None,
            }
        }
        _ => None,
    }
}

/// Extract tool name from a tool_use assistant message.
fn extract_tool_name(event_type: &str, msg: Option<&RawMessage>) -> Option<String> {
    if event_type != "assistant" {
        return None;
    }
    let msg = msg?;
    if let Some(serde_json::Value::Array(arr)) = &msg.content {
        for item in arr {
            if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
                return item.get("name").and_then(|n| n.as_str()).map(String::from);
            }
        }
    }
    None
}

/// Extract the text content from a user message.
fn extract_user_text(msg: &RawMessage) -> Option<String> {
    match &msg.content {
        Some(serde_json::Value::String(s)) => {
            let trimmed = s.trim();
            if trimmed.is_empty() || trimmed.starts_with('<') {
                None
            } else {
                Some(trimmed.to_string())
            }
        }
        Some(serde_json::Value::Array(arr)) => {
            for item in arr {
                if item.get("type").and_then(|t| t.as_str()) == Some("text")
                    && let Some(text) = item.get("text").and_then(|t| t.as_str())
                {
                    let trimmed = text.trim();
                    if !trimmed.is_empty() && !trimmed.starts_with('<') {
                        return Some(trimmed.to_string());
                    }
                }
            }
            None
        }
        _ => None,
    }
}

/// Extract the raw project hash from a session file path.
fn extract_project_name(path: &str) -> String {
    // Path: .../projects/{project-hash}/{session_id}.jsonl
    let parts: Vec<&str> = path.split('/').collect();
    for (i, part) in parts.iter().enumerate() {
        if *part == "projects" && i + 1 < parts.len() {
            return parts[i + 1].to_string();
        }
    }
    "unknown".to_string()
}

/// Decode a Claude Code project hash into a human-readable name.
/// Claude encodes workspace paths by replacing `/` with `-`, e.g.
/// `/Users/peter/oss/kap` → `-Users-peter-oss-kap`.
/// We strip known home directory prefixes for readability.
fn decode_project_name(encoded: &str) -> String {
    let stripped = encoded.strip_prefix('-').unwrap_or(encoded);
    // Strip home dir prefix: "Users-{user}-..." or "home-{user}-..."
    for prefix in &["Users-", "home-"] {
        if let Some(after) = stripped.strip_prefix(prefix)
            && let Some((_, rest)) = after.split_once('-')
        {
            return rest.to_string();
        }
    }
    stripped.to_string()
}

/// Find the full path to a session's JSONL file in the container.
fn find_session_path(app_container: &str, session_id: &str) -> Result<String> {
    let output = containers::exec_in(
        app_container,
        &[
            "sh",
            "-c",
            &format!(
                "for d in /home/*/.claude/projects /root/.claude/projects; do \
                   [ -d \"$d\" ] && find \"$d\" -maxdepth 2 -name '{session_id}.jsonl' 2>/dev/null; \
                 done | head -1"
            ),
        ],
    )
    .ok_or_else(|| anyhow::anyhow!("session {session_id} not found"))?;

    let path = output.trim().to_string();
    if path.is_empty() {
        anyhow::bail!("session {session_id} not found");
    }
    Ok(path)
}

/// Check if a session is actively being written to (file modified in last 15s).
/// More reliable than process detection — avoids false positives from background
/// Claude daemons that persist between turns.
pub fn is_session_active(app_container: &str, session_id: &str) -> bool {
    // find the session file modified within the last 15 seconds
    let output = containers::exec_in(
        app_container,
        &[
            "sh",
            "-c",
            &format!(
                "for d in /home/*/.claude/projects /root/.claude/projects; do \
                   [ -d \"$d\" ] && find \"$d\" -maxdepth 2 -name '{session_id}.jsonl' -mmin -0.25 2>/dev/null; \
                 done | head -1"
            ),
        ],
    );
    output
        .as_ref()
        .map(|s| !s.trim().is_empty())
        .unwrap_or(false)
}

/// Check if any Claude Code process is running in the container (for cancel).
pub fn is_agent_running(app_container: &str) -> Option<u32> {
    let output = containers::exec_in(app_container, &["pgrep", "-f", "claude"])?;
    output.lines().next()?.trim().parse().ok()
}

/// Get the git diff from the app container, using the session's working directory
/// if available (supports worktree-based agents).
pub fn get_diff(app_container: &str, session_id: &str) -> Result<String> {
    let cwd = find_session_cwd(app_container, session_id);
    match cwd {
        Some(dir) => containers::exec_in(app_container, &["git", "-C", &dir, "diff"]),
        None => containers::exec_in(app_container, &["git", "diff"]),
    }
    .ok_or_else(|| anyhow::anyhow!("failed to get git diff"))
}

/// Extract the working directory from a session's JSONL file.
pub fn find_session_cwd(app_container: &str, session_id: &str) -> Option<String> {
    let path = find_session_path(app_container, session_id).ok()?;
    let output = containers::exec_in(app_container, &["head", "-5", &path])?;
    parse_cwd_from_jsonl(&output)
}

/// Parse the first `cwd` field from JSONL content.
fn parse_cwd_from_jsonl(jsonl: &str) -> Option<String> {
    for line in jsonl.lines() {
        if let Ok(evt) = serde_json::from_str::<RawEvent>(line)
            && let Some(cwd) = evt.cwd
        {
            return Some(cwd);
        }
    }
    None
}

/// Find the PID of the claude process for a specific session.
///
/// If only one claude process is running, returns it directly (backward compat).
/// If multiple, matches by comparing each process's `/proc/<pid>/cwd` against
/// the session's working directory from its JSONL file.
pub fn find_pid_for_session(app_container: &str, session_id: &str) -> Option<u32> {
    let pids = find_all_claude_pids(app_container)?;

    if pids.len() == 1 {
        return Some(pids[0]);
    }

    // Multiple processes: match by cwd
    let session_cwd = find_session_cwd(app_container, session_id)?;
    let pid_list = pids
        .iter()
        .map(|p| p.to_string())
        .collect::<Vec<_>>()
        .join(" ");
    let cmd = format!(
        "for pid in {pid_list}; do \
           cwd=$(readlink /proc/$pid/cwd 2>/dev/null) && \
           [ \"$cwd\" = {} ] && echo $pid && break; \
         done",
        shell_escape(&session_cwd),
    );
    let output = containers::exec_in(app_container, &["sh", "-c", &cmd])?;
    output.trim().parse().ok()
}

/// Get all PIDs of claude processes in the container.
fn find_all_claude_pids(app_container: &str) -> Option<Vec<u32>> {
    let output = containers::exec_in(app_container, &["pgrep", "-f", "claude"])?;
    let pids: Vec<u32> = output
        .lines()
        .filter_map(|line| line.trim().parse().ok())
        .collect();
    if pids.is_empty() { None } else { Some(pids) }
}

/// Extract worktree name from a cwd path.
/// e.g. `/workspace/.worktrees/flocks-fix-auth` → `Some("flocks-fix-auth")`
fn extract_worktree_name(cwd: Option<&str>) -> Option<String> {
    let cwd = cwd?;
    let idx = cwd.find("/.worktrees/")?;
    let after = &cwd[idx + "/.worktrees/".len()..];
    let name = after.split('/').next().unwrap_or(after);
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

/// Shell-escape a string for safe embedding in sh -c commands.
fn shell_escape(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

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

    #[test]
    fn parse_user_message() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"fix the tests"},"timestamp":"2026-03-07T10:00:00Z","uuid":"abc"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, "user");
        assert_eq!(events[0].role.as_deref(), Some("user"));
        assert_eq!(events[0].summary.as_deref(), Some("fix the tests"));
    }

    #[test]
    fn parse_assistant_text() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix those tests."}]},"timestamp":"2026-03-07T10:00:01Z","uuid":"def"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, "assistant");
        assert_eq!(events[0].summary.as_deref(), Some("I'll fix those tests."));
    }

    #[test]
    fn parse_assistant_tool_use() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"t1","input":{}}]},"timestamp":"2026-03-07T10:00:02Z","uuid":"ghi"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        // Empty input: falls back to "tool_use: Read"
        assert_eq!(events[0].summary.as_deref(), Some("tool_use: Read"));
        assert_eq!(events[0].tool_name.as_deref(), Some("Read"));
    }

    #[test]
    fn parse_thinking() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"let me think..."}]},"timestamp":"2026-03-07T10:00:03Z","uuid":"jkl"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].summary.as_deref(), Some("thinking…"));
    }

    #[test]
    fn filters_out_progress_and_snapshots() {
        let jsonl = "
{\"type\":\"progress\",\"timestamp\":\"2026-03-07T10:00:00Z\"}
{\"type\":\"file-history-snapshot\",\"messageId\":\"abc\",\"snapshot\":{}}
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-07T10:00:01Z\",\"uuid\":\"1\"}
";
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, "user");
    }

    #[test]
    fn filters_out_meta_commands() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"<local-command-caveat>..."},"isMeta":true,"uuid":"1","timestamp":"2026-03-07T10:00:00Z"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 0);
    }

    #[test]
    fn truncates_long_content() {
        let long_msg = "x".repeat(300);
        let jsonl = format!(
            r#"{{"type":"user","message":{{"role":"user","content":"{long_msg}"}},"timestamp":"2026-03-07T10:00:00Z","uuid":"1"}}"#
        );
        let events = parse_session_events(&jsonl);
        assert_eq!(events.len(), 1);
        let summary = events[0].summary.as_ref().unwrap();
        assert!(summary.len() < 210);
        assert!(summary.ends_with('…'));
    }

    #[test]
    fn extract_project_name_from_path() {
        assert_eq!(
            extract_project_name("/home/user/.claude/projects/-Users-peter-oss-foo/abc.jsonl"),
            "-Users-peter-oss-foo"
        );
    }

    #[test]
    fn decode_project_name_macos() {
        assert_eq!(decode_project_name("-Users-peter-oss-kap"), "oss-kap");
    }

    #[test]
    fn decode_project_name_linux() {
        assert_eq!(
            decode_project_name("-home-ubuntu-projects-my-app"),
            "projects-my-app"
        );
    }

    #[test]
    fn decode_project_name_tmp() {
        assert_eq!(
            decode_project_name("-tmp-cambria-quick-test"),
            "tmp-cambria-quick-test"
        );
    }

    #[test]
    fn decode_project_name_no_prefix() {
        assert_eq!(decode_project_name("some-project"), "some-project");
    }

    #[test]
    fn decode_project_name_short_path() {
        // Only username, no subdirectory
        assert_eq!(decode_project_name("-Users-peter"), "Users-peter");
    }

    #[test]
    fn parse_with_model() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"hi"}]},"timestamp":"2026-03-07T10:00:00Z","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].model.as_deref(), Some("claude-opus-4-6"));
    }

    #[test]
    fn empty_jsonl_returns_empty() {
        let events = parse_session_events("");
        assert!(events.is_empty());
    }

    #[test]
    fn invalid_json_lines_skipped() {
        let jsonl = "not json\n{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"uuid\":\"1\",\"timestamp\":\"t\"}\nalso not json\n";
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].summary.as_deref(), Some("hello"));
    }

    #[test]
    fn system_event_parsed() {
        let jsonl = r#"{"type":"system","subtype":"stop_hook_summary","timestamp":"2026-03-07T10:00:00Z","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, "system");
        assert_eq!(events[0].summary.as_deref(), Some("system event"));
    }

    #[test]
    fn queue_operation_filtered_out() {
        let jsonl = r#"{"type":"queue-operation","operation":"enqueue","content":"do something","timestamp":"2026-03-07T10:00:00Z"}"#;
        let events = parse_session_events(jsonl);
        assert!(events.is_empty());
    }

    #[test]
    fn last_prompt_filtered_out() {
        let jsonl = r#"{"type":"last-prompt","lastPrompt":"fix tests","sessionId":"abc"}"#;
        let events = parse_session_events(jsonl);
        assert!(events.is_empty());
    }

    #[test]
    fn tool_result_user_message_has_summary() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]},"uuid":"1","timestamp":"t"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].role.as_deref(), Some("user"));
        assert_eq!(events[0].summary.as_deref(), Some("ok"));
    }

    #[test]
    fn tool_result_error_shows_prefix() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"command not found","is_error":true}]},"uuid":"1","timestamp":"t"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(
            events[0].summary.as_deref(),
            Some("(error) command not found")
        );
    }

    #[test]
    fn tool_result_empty_content() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":""}]},"uuid":"1","timestamp":"t"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("(empty result)"));
    }

    #[test]
    fn tool_result_array_content() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"file contents here"}]}]},"uuid":"1","timestamp":"t"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("file contents here"));
    }

    #[test]
    fn tool_use_bash_shows_command() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"ls -la /tmp"}}]},"timestamp":"t","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("ls -la /tmp"));
        assert_eq!(events[0].tool_name.as_deref(), Some("Bash"));
    }

    #[test]
    fn tool_use_read_shows_file_path() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"t1","input":{"file_path":"/foo/bar.rs"}}]},"timestamp":"t","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("/foo/bar.rs"));
    }

    #[test]
    fn tool_use_grep_shows_pattern() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Grep","id":"t1","input":{"pattern":"fn main"}}]},"timestamp":"t","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("fn main"));
    }

    #[test]
    fn tool_use_unknown_tool_no_input() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"CustomTool","id":"t1","input":{"stuff":123}}]},"timestamp":"t","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("tool_use: CustomTool"));
    }

    #[test]
    fn tool_use_multiline_bash_truncated() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"echo hello\necho world\necho done"}}]},"timestamp":"t","uuid":"1"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events[0].summary.as_deref(), Some("echo hello…"));
    }

    #[test]
    fn compact_tool_input_basic() {
        let input = serde_json::json!({"command": "git status"});
        assert_eq!(
            compact_tool_input("Bash", &input),
            Some("git status".to_string())
        );

        let input = serde_json::json!({"file_path": "/src/main.rs"});
        assert_eq!(
            compact_tool_input("Read", &input),
            Some("/src/main.rs".to_string())
        );

        let input = serde_json::json!({});
        assert_eq!(compact_tool_input("Bash", &input), None);
    }

    #[test]
    fn session_event_serialization_skips_none_fields() {
        let event = SessionEvent {
            event_type: "user".to_string(),
            role: Some("user".to_string()),
            timestamp: None,
            summary: Some("hello".to_string()),
            model: None,
            tool_name: None,
            uuid: None,
        };
        let json = serde_json::to_string(&event).unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert!(v.get("timestamp").is_none());
        assert!(v.get("model").is_none());
        assert!(v.get("tool_name").is_none());
        assert_eq!(v["type"], "user");
        assert_eq!(v["summary"], "hello");
    }

    #[test]
    fn extract_project_name_no_projects_dir() {
        assert_eq!(extract_project_name("/some/random/path.jsonl"), "unknown");
    }

    #[test]
    fn extract_user_text_string_content() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: Some(serde_json::Value::String("fix the tests".into())),
        };
        assert_eq!(extract_user_text(&msg), Some("fix the tests".into()));
    }

    #[test]
    fn extract_user_text_skips_meta() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: Some(serde_json::Value::String("<local-command...>".into())),
        };
        assert_eq!(extract_user_text(&msg), None);
    }

    #[test]
    fn extract_user_text_array_content() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: Some(serde_json::json!([{"type": "text", "text": "add streaming"}])),
        };
        assert_eq!(extract_user_text(&msg), Some("add streaming".into()));
    }

    #[test]
    fn extract_user_text_empty_string() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: Some(serde_json::Value::String("".into())),
        };
        assert_eq!(extract_user_text(&msg), None);
    }

    #[test]
    fn extract_user_text_none_content() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: None,
        };
        assert_eq!(extract_user_text(&msg), None);
    }

    #[test]
    fn extract_user_text_array_skips_tool_result() {
        let msg = RawMessage {
            role: Some("user".into()),
            model: None,
            content: Some(serde_json::json!([
                {"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
                {"type": "text", "text": "now fix the tests"}
            ])),
        };
        assert_eq!(extract_user_text(&msg), Some("now fix the tests".into()));
    }

    #[test]
    fn decode_project_name_workspace() {
        // Inside devcontainers, workspace path is /workspace
        assert_eq!(decode_project_name("-workspace"), "workspace");
    }

    #[test]
    fn parse_cwd_from_jsonl_extracts_cwd() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"fix tests"},"cwd":"/workspace/.worktrees/flocks-fix-auth","timestamp":"2026-03-07T10:00:00Z","uuid":"1"}"#;
        assert_eq!(
            parse_cwd_from_jsonl(jsonl),
            Some("/workspace/.worktrees/flocks-fix-auth".to_string())
        );
    }

    #[test]
    fn parse_cwd_from_jsonl_skips_events_without_cwd() {
        let jsonl = "{\"type\":\"system\",\"timestamp\":\"t\"}\n\
                     {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"cwd\":\"/workspace\",\"timestamp\":\"t\",\"uuid\":\"1\"}";
        assert_eq!(parse_cwd_from_jsonl(jsonl), Some("/workspace".to_string()));
    }

    #[test]
    fn parse_cwd_from_jsonl_returns_none_when_absent() {
        let jsonl = r#"{"type":"system","timestamp":"t"}"#;
        assert_eq!(parse_cwd_from_jsonl(jsonl), None);
    }

    #[test]
    fn worktree_detected_from_cwd() {
        assert_eq!(
            extract_worktree_name(Some("/workspace/.worktrees/flocks-fix-auth")),
            Some("flocks-fix-auth".to_string())
        );
    }

    #[test]
    fn worktree_detected_nested_path() {
        assert_eq!(
            extract_worktree_name(Some("/workspace/.worktrees/task-123/subdir")),
            Some("task-123".to_string())
        );
    }

    #[test]
    fn no_worktree_for_regular_cwd() {
        assert_eq!(extract_worktree_name(Some("/workspace")), None);
    }

    #[test]
    fn no_worktree_for_none() {
        assert_eq!(extract_worktree_name(None), None);
    }

    #[test]
    fn session_info_includes_cwd_and_worktree() {
        let info = SessionInfo {
            id: "abc".into(),
            project: "test".into(),
            task: None,
            branch: None,
            cwd: Some("/workspace/.worktrees/flocks-task".into()),
            worktree: Some("flocks-task".into()),
            last_event: None,
            event_count: 0,
        };
        let json: serde_json::Value = serde_json::to_value(&info).unwrap();
        assert_eq!(json["cwd"], "/workspace/.worktrees/flocks-task");
        assert_eq!(json["worktree"], "flocks-task");
    }

    #[test]
    fn session_info_omits_none_cwd_and_worktree() {
        let info = SessionInfo {
            id: "abc".into(),
            project: "test".into(),
            task: None,
            branch: None,
            cwd: None,
            worktree: None,
            last_event: None,
            event_count: 0,
        };
        let json: serde_json::Value = serde_json::to_value(&info).unwrap();
        assert!(json.get("cwd").is_none());
        assert!(json.get("worktree").is_none());
    }

    #[test]
    fn session_info_includes_task() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"fix the auth bug"},"timestamp":"2026-03-07T10:00:00Z","uuid":"1","gitBranch":"main"}"#;
        let events = parse_session_events(jsonl);
        assert_eq!(events.len(), 1);
        // Task extraction happens in discover_sessions (from head lines),
        // but verify the underlying user text extraction works end-to-end
        let raw: RawEvent = serde_json::from_str(jsonl).unwrap();
        let task = extract_user_text(raw.message.as_ref().unwrap());
        assert_eq!(task, Some("fix the auth bug".into()));
    }

    #[test]
    fn sessions_sorted_by_last_event() {
        let mut sessions = vec![
            SessionInfo {
                id: "old".into(),
                project: "test".into(),
                task: None,
                branch: None,
                cwd: None,
                worktree: None,
                last_event: Some("2026-03-07T08:00:00Z".into()),
                event_count: 5,
            },
            SessionInfo {
                id: "new".into(),
                project: "test".into(),
                task: None,
                branch: None,
                cwd: None,
                worktree: None,
                last_event: Some("2026-03-07T12:00:00Z".into()),
                event_count: 10,
            },
            SessionInfo {
                id: "none".into(),
                project: "test".into(),
                task: None,
                branch: None,
                cwd: None,
                worktree: None,
                last_event: None,
                event_count: 1,
            },
        ];
        sessions.sort_by(|a, b| b.last_event.cmp(&a.last_event));
        assert_eq!(sessions[0].id, "new");
        assert_eq!(sessions[1].id, "old");
        assert_eq!(sessions[2].id, "none");
    }
}
