//! Deterministic, pure extraction of a size-capped digest from a Claude Code
//! session transcript (JSONL), with no LLM call involved.

/// Build a deterministic digest of a Claude Code session transcript.
///
/// Reads NDJSON `jsonl` (one Claude Code transcript event per line) and keeps
/// only:
/// - `user` entries whose `message.content` is a plain string (real user
///   text), included verbatim.
/// - `assistant` entries' final text content, included verbatim, plus each
///   `tool_use` block reduced to `name(one-line json args)`.
///
/// Excluded: `tool_result` content (found in `user` entries whose content is
/// an array), `thinking` blocks, and any event whose top-level `type` is not
/// `user` or `assistant` (e.g. `queue-operation`, `attachment`, `summary`,
/// `system`, `stream_event`). Sidechain entries (subagent turns) are also
/// excluded so the digest reflects only the main conversation thread.
///
/// Lines that fail to parse as JSON are skipped rather than treated as fatal.
///
/// When the joined digest exceeds `max_bytes`, entries are dropped from the
/// oldest end first so the most recent content survives the cap.
pub fn build_digest(jsonl: &str, max_bytes: usize) -> String {
    let entries = extract_entries(jsonl);
    cap_to_bytes(&entries, max_bytes)
}

/// Parse `jsonl` into an ordered list of digest lines, applying the
/// extraction/exclusion rules. Pure — no truncation is applied here.
fn extract_entries(jsonl: &str) -> Vec<String> {
    let mut entries = Vec::new();
    for line in jsonl.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
            continue;
        };
        if value.get("isSidechain").and_then(|v| v.as_bool()) == Some(true) {
            continue;
        }
        match value.get("type").and_then(|t| t.as_str()) {
            Some("user") => {
                if let Some(text) = extract_user_text(&value) {
                    entries.push(format!("User: {text}"));
                }
            }
            Some("assistant") => {
                entries.extend(extract_assistant_lines(&value));
            }
            _ => {}
        }
    }
    entries
}

/// Extract verbatim user text from a `user`-typed transcript entry.
///
/// Returns `None` when the message content is an array (tool results) rather
/// than a plain string — tool results are excluded from the digest.
fn extract_user_text(value: &serde_json::Value) -> Option<String> {
    let content = value.get("message")?.get("content")?;
    content.as_str().map(str::to_string)
}

/// Extract digest lines from an `assistant`-typed transcript entry: the final
/// text reply verbatim, followed by one line per `tool_use` block. `thinking`
/// blocks are skipped.
fn extract_assistant_lines(value: &serde_json::Value) -> Vec<String> {
    let mut lines = Vec::new();
    let Some(content) = value
        .get("message")
        .and_then(|m| m.get("content"))
        .and_then(|c| c.as_array())
    else {
        return lines;
    };

    let mut text_parts = Vec::new();
    for block in content {
        match block.get("type").and_then(|t| t.as_str()) {
            Some("text") => {
                if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
                    text_parts.push(text);
                }
            }
            Some("tool_use") => {
                let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
                let args = block
                    .get("input")
                    .map(serde_json::Value::to_string)
                    .unwrap_or_default();
                lines.push(format!("Tool: {name}({args})"));
            }
            // "thinking" and any other block types are excluded.
            _ => {}
        }
    }
    if !text_parts.is_empty() {
        lines.insert(0, format!("Assistant: {}", text_parts.join("\n")));
    }
    lines
}

/// Join `entries` with newlines, dropping the oldest entries first so the
/// digest stays within `max_bytes`. Always keeps at least the single newest
/// entry, even if it alone exceeds the cap.
fn cap_to_bytes(entries: &[String], max_bytes: usize) -> String {
    let joined = entries.join("\n");
    if joined.len() <= max_bytes {
        return joined;
    }

    let mut kept: Vec<&str> = Vec::new();
    let mut total = 0usize;
    for entry in entries.iter().rev() {
        let addition = entry.len() + if kept.is_empty() { 0 } else { 1 };
        if total + addition > max_bytes && !kept.is_empty() {
            break;
        }
        total += addition;
        kept.push(entry);
    }
    kept.reverse();
    kept.join("\n")
}

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

    // -- extraction / exclusion rules ---------------------------------------

    #[test]
    fn extracts_plain_user_text_verbatim() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"hello there"}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "User: hello there");
    }

    #[test]
    fn extracts_assistant_final_text_verbatim() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"here is my answer"}]}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "Assistant: here is my answer");
    }

    #[test]
    fn extracts_tool_use_as_name_and_one_line_args() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}]}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, r#"Tool: Bash({"command":"ls -la"})"#);
        assert!(!digest.contains('\n'), "tool args must be one line");
    }

    #[test]
    fn excludes_tool_result_user_entries() {
        // A user entry carrying a tool_result array (not plain string content)
        // must be excluded entirely from the digest.
        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"abc","content":"file contents..."}]}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "", "tool_result entries must be excluded");
    }

    #[test]
    fn excludes_thinking_blocks() {
        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"let me consider..."},{"type":"text","text":"final answer"}]}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "Assistant: final answer");
        assert!(!digest.contains("let me consider"));
    }

    #[test]
    fn excludes_non_user_assistant_event_types() {
        let jsonl = "\
{\"type\":\"queue-operation\",\"operation\":\"enqueue\",\"content\":\"noise\"}
{\"type\":\"attachment\",\"attachment\":{\"type\":\"skill_listing\"}}
{\"type\":\"summary\",\"summary\":\"irrelevant\"}
{\"type\":\"system\",\"content\":\"system noise\"}
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"real message\"}}";
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "User: real message");
    }

    #[test]
    fn excludes_sidechain_entries() {
        let jsonl = r#"{"type":"user","isSidechain":true,"message":{"role":"user","content":"subagent chatter"}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "", "sidechain (subagent) entries must be excluded");
    }

    #[test]
    fn deterministic_across_repeated_calls() {
        let jsonl = "\
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"a\"}}
{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"b\"}]}}";
        let first = build_digest(jsonl, 10_000);
        let second = build_digest(jsonl, 10_000);
        assert_eq!(first, second);
    }

    // -- cap-truncation keeping newest ---------------------------------------

    #[test]
    fn cap_truncation_keeps_newest_entries() {
        let jsonl = "\
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"oldest message\"}}
{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"middle reply\"}]}}
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"newest message\"}}";
        // Cap small enough to only fit the last entry.
        let digest = build_digest(jsonl, 20);
        assert_eq!(digest, "User: newest message");
        assert!(!digest.contains("oldest"));
    }

    #[test]
    fn cap_truncation_keeps_at_least_one_entry_even_if_oversized() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"this single message is longer than the cap"}}"#;
        let digest = build_digest(jsonl, 5);
        assert!(
            digest.contains("this single message"),
            "must keep the single newest entry even if it exceeds max_bytes: {digest}"
        );
    }

    #[test]
    fn no_truncation_when_under_cap() {
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"short"}}"#;
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "User: short");
    }

    // -- malformed-line tolerance ---------------------------------------------

    #[test]
    fn tolerates_malformed_lines_between_valid_ones() {
        let jsonl = "\
{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"first\"}}
not json at all {{{
{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}";
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "User: first\nAssistant: second");
    }

    #[test]
    fn tolerates_empty_lines() {
        let jsonl = "\n{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n\n";
        let digest = build_digest(jsonl, 10_000);
        assert_eq!(digest, "User: hi");
    }

    #[test]
    fn empty_input_yields_empty_digest() {
        assert_eq!(build_digest("", 10_000), "");
    }

    #[test]
    fn all_malformed_yields_empty_digest() {
        let jsonl = "garbage\nmore garbage\n{not valid";
        assert_eq!(build_digest(jsonl, 10_000), "");
    }
}
