#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

//! Integration tests for the Claude Code harness.
//!
//! These check the two fidelity guarantees from the crate docs — native ↔ disk
//! is byte-faithful (Store round-trip), and native → Common → native is
//! semantically lossless (Codec fixpoint) — plus that `to_common` actually
//! extracts the right conversation.

use chrono::{DateTime, Utc};
use serde_json::json;
use txcript::{
    Block, ClaudeCode, ClaudeStore, Codec, Common, Message, Meta, Role, StopReason, Store, Tool,
    ToolOutput, Transcript, Usage,
};

fn ts(s: &str) -> DateTime<Utc> {
    s.parse().unwrap()
}

/// A realistic single session: a user ask, an assistant turn that thinks and
/// calls Edit, the tool result, and a final assistant turn with usage. Plus
/// non-message lines (summary, custom-title, a snapshot) that must survive on
/// disk but never become messages.
fn sample_jsonl() -> String {
    let lines = [
        json!({"type": "summary", "summary": "Fix the parser", "leafUuid": "abc"}),
        json!({"type": "custom-title", "customTitle": "Parser work"}),
        json!({
            "type": "user", "uuid": "u1", "parentUuid": null,
            "sessionId": "sess-1", "cwd": "/work/repo", "gitBranch": "main",
            "version": "1.2.3", "timestamp": "2026-01-02T03:04:05.000Z",
            "message": {"role": "user", "content": "fix the off-by-one"},
        }),
        json!({
            "type": "assistant", "uuid": "a1", "parentUuid": "u1",
            "sessionId": "sess-1", "timestamp": "2026-01-02T03:04:06.000Z",
            "message": {
                "role": "assistant",
                "model": "claude-opus-4-8",
                "content": [
                    {"type": "thinking", "thinking": "off-by-one in the loop", "signature": "sig-xyz"},
                    {"type": "text", "text": "Patching the bound."},
                    {"type": "tool_use", "id": "t1", "name": "Edit", "input": {
                        "file_path": "/work/repo/src/p.rs",
                        "old_string": "i <= n", "new_string": "i < n"
                    }},
                ],
            },
        }),
        json!({
            "type": "user", "uuid": "u2", "parentUuid": "a1",
            "sessionId": "sess-1", "timestamp": "2026-01-02T03:04:07.000Z",
            "message": {"role": "user", "content": [
                {"type": "tool_result", "tool_use_id": "t1", "content": "applied"},
            ]},
        }),
        json!({
            "type": "assistant", "uuid": "a2", "parentUuid": "u2",
            "sessionId": "sess-1", "timestamp": "2026-01-02T03:04:08.000Z",
            "message": {
                "role": "assistant",
                "model": "claude-opus-4-8",
                "stop_reason": "end_turn",
                "usage": {"input_tokens": 100, "output_tokens": 20, "cache_read_input_tokens": 50},
                "content": [{"type": "text", "text": "Done."}],
            },
        }),
        // A line type the codec doesn't model — must round-trip untouched.
        json!({"type": "file-history-snapshot", "snapshot": {"files": ["a", "b"]}}),
    ];
    lines
        .iter()
        .map(|l| l.to_string())
        .collect::<Vec<_>>()
        .join("\n")
        + "\n"
}

#[test]
fn store_round_trip_is_lossless_on_disk() {
    let dir = tempfile::tempdir().unwrap();
    let store = ClaudeStore::new(dir.path());

    let src = dir.path().join("orig.jsonl");
    std::fs::write(&src, sample_jsonl()).unwrap();

    let loaded = store.load(&src).unwrap();
    let saved = store.save(&loaded).unwrap();
    let reloaded = store.load(&saved.reference).unwrap();

    // Every native record — including the summary, title, and snapshot lines
    // the codec ignores — survives a load→save→load cycle unchanged.
    assert_eq!(loaded.body, reloaded.body);
    // And the on-disk landing spot is derived from the session metadata.
    assert!(saved.reference.ends_with("sess-1.jsonl"));
}

#[test]
fn discover_extracts_session_metadata() {
    let dir = tempfile::tempdir().unwrap();
    let project = dir.path().join("-work-repo");
    std::fs::create_dir_all(&project).unwrap();
    std::fs::write(project.join("sess-1.jsonl"), sample_jsonl()).unwrap();

    let store = ClaudeStore::new(dir.path());
    let found = store.discover().unwrap();
    assert_eq!(found.len(), 1);
    let meta = &found[0].meta;
    assert_eq!(meta.id, "sess-1");
    assert_eq!(meta.cwd.as_deref(), Some("/work/repo"));
    assert_eq!(meta.git_branch.as_deref(), Some("main"));
    assert_eq!(meta.model.as_deref(), Some("claude-opus-4-8"));
    assert_eq!(meta.cli_version.as_deref(), Some("1.2.3"));
    // custom-title wins over the summary line.
    assert_eq!(meta.title.as_deref(), Some("Parser work"));
    assert_eq!(meta.timestamp, ts("2026-01-02T03:04:05.000Z"));
}

#[test]
fn to_common_extracts_the_conversation_faithfully() {
    let dir = tempfile::tempdir().unwrap();
    let store = ClaudeStore::new(dir.path());
    let src = dir.path().join("s.jsonl");
    std::fs::write(&src, sample_jsonl()).unwrap();

    let common = ClaudeCode::to_common(&store.load(&src).unwrap()).unwrap();
    let msgs = &common.body;

    // Four conversational turns; the summary/title/snapshot lines are dropped.
    assert_eq!(msgs.len(), 4);

    assert_eq!(msgs[0].role, Role::User);
    assert!(matches!(&msgs[0].content[0], Block::Text { text } if text == "fix the off-by-one"));

    // Assistant turn: thinking (with signature), text, and a typed Edit.
    assert_eq!(msgs[1].role, Role::Assistant);
    assert_eq!(msgs[1].model.as_deref(), Some("claude-opus-4-8"));
    assert!(matches!(
        &msgs[1].content[0],
        Block::Thinking { text, signature: Some(s), .. } if text == "off-by-one in the loop" && s == "sig-xyz"
    ));
    match &msgs[1].content[2] {
        Block::ToolUse {
            id,
            tool:
                Tool::Edit {
                    file_path,
                    old_string,
                    new_string,
                    ..
                },
        } => {
            assert_eq!(id, "t1");
            assert_eq!(file_path, "/work/repo/src/p.rs");
            assert_eq!(old_string, "i <= n");
            assert_eq!(new_string, "i < n");
        }
        other => panic!("expected Edit tool_use, got {other:?}"),
    }

    // Tool result rides on a User message (Anthropic convention).
    assert_eq!(msgs[2].role, Role::User);
    assert!(matches!(
        &msgs[2].content[0],
        Block::ToolResult { tool_use_id, content: ToolOutput::Text(t), is_error: false }
            if tool_use_id == "t1" && t == "applied"
    ));

    // Final turn carries usage and stop reason.
    assert_eq!(msgs[3].stop_reason, Some(StopReason::EndTurn));
    let usage = msgs[3].usage.unwrap();
    assert_eq!(usage.input_tokens, 100);
    assert_eq!(usage.cache_read_input_tokens, Some(50));
}

/// A Common transcript covering every block kind, used to prove the codec
/// fixpoint `to_common(from_common(c)) == c`.
fn sample_common() -> Transcript<Common> {
    let meta = Meta {
        id: "sess-1".into(),
        timestamp: ts("2026-01-02T03:04:05.000Z"),
        cwd: Some("/work/repo".into()),
        git_branch: Some("main".into()),
        title: Some("Parser work".into()),
        cli_version: Some("1.2.3".into()),
        model: Some("claude-opus-4-8".into()),
    };
    let body = vec![
        Message {
            role: Role::User,
            content: vec![Block::Text {
                text: "fix it".into(),
            }],
            timestamp: ts("2026-01-02T03:04:05.000Z"),
            model: None,
            stop_reason: None,
            usage: None,
        },
        Message {
            role: Role::Assistant,
            content: vec![
                Block::Thinking {
                    text: "thinking".into(),
                    signature: Some("sig".into()),
                    encrypted: None,
                },
                Block::Text {
                    text: "patching".into(),
                },
                Block::ToolUse {
                    id: "t1".into(),
                    tool: Tool::Edit {
                        file_path: "/a.rs".into(),
                        old_string: "x".into(),
                        new_string: "y".into(),
                        replace_all: false,
                    },
                },
            ],
            timestamp: ts("2026-01-02T03:04:06.000Z"),
            model: Some("claude-opus-4-8".into()),
            stop_reason: Some(StopReason::ToolUse),
            usage: None,
        },
        Message {
            role: Role::User,
            content: vec![Block::ToolResult {
                tool_use_id: "t1".into(),
                content: ToolOutput::Text("ok".into()),
                is_error: false,
            }],
            timestamp: ts("2026-01-02T03:04:07.000Z"),
            model: None,
            stop_reason: None,
            usage: None,
        },
        Message {
            role: Role::Assistant,
            content: vec![Block::Text {
                text: "done".into(),
            }],
            timestamp: ts("2026-01-02T03:04:08.000Z"),
            model: Some("claude-opus-4-8".into()),
            stop_reason: Some(StopReason::EndTurn),
            usage: Some(Usage {
                input_tokens: 100,
                output_tokens: 20,
                cache_read_input_tokens: Some(50),
                cache_creation_input_tokens: None,
            }),
        },
    ];
    Transcript::new(meta, body)
}

#[test]
fn codec_fixpoint_through_common_loses_nothing() {
    let common = sample_common();
    let native = ClaudeCode::from_common(&common).unwrap();
    let back = ClaudeCode::to_common(&native).unwrap();
    assert_eq!(common, back);
}

/// `from_common` is a pure function: same input, identical output (deterministic
/// uuids), so conversions are reproducible.
#[test]
fn from_common_is_deterministic() {
    let common = sample_common();
    let a = serde_json::to_value(ClaudeCode::from_common(&common).unwrap().body).unwrap();
    let b = serde_json::to_value(ClaudeCode::from_common(&common).unwrap().body).unwrap();
    assert_eq!(a, b);
}
