use rusqlite::Connection;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_temp_dir(prefix: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("time")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("ccstats-{prefix}-{}-{nanos}", std::process::id()));
    fs::create_dir_all(&dir).expect("create temp dir");
    dir
}

fn write_file(path: &Path, content: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create parent dirs");
    }
    fs::write(path, content).expect("write test file");
}

fn write_cursor_state_db(path: &Path) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create cursor db parent");
    }
    let conn = Connection::open(path).expect("open cursor db");
    conn.execute(
        "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)",
        [],
    )
    .expect("create cursorDiskKV");
    conn.execute(
        "INSERT INTO cursorDiskKV (key, value) VALUES (?1, ?2)",
        (
            "composerData:composer-1",
            r#"{"composerId":"composer-1","modelConfig":{"modelName":"claude-4-sonnet"},"workspaceIdentifier":{"uri":{"fsPath":"/tmp/cursor-project"}}}"#,
        ),
    )
    .expect("insert composer");
    conn.execute(
        "INSERT INTO cursorDiskKV (key, value) VALUES (?1, ?2)",
        (
            "bubbleId:composer-1:bubble-1",
            r#"{"createdAt":"2026-02-06T10:00:00Z","tokenCount":{"inputTokens":100,"outputTokens":40}}"#,
        ),
    )
    .expect("insert bubble");
}

fn resolve_ccstats_binary() -> PathBuf {
    if let Some(bin) = std::env::var_os("CARGO_BIN_EXE_ccstats") {
        return PathBuf::from(bin);
    }

    let bin_name = if cfg!(windows) {
        "ccstats.exe"
    } else {
        "ccstats"
    };
    let mut candidates = Vec::new();

    if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
        candidates.push(PathBuf::from(target_dir).join("debug").join(bin_name));
    }

    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    candidates.push(
        manifest_dir
            .join("target")
            .join("llvm-cov-target")
            .join("debug")
            .join(bin_name),
    );
    candidates.push(manifest_dir.join("target").join("debug").join(bin_name));

    if let Some(bin) = candidates.iter().find(|path| path.is_file()) {
        return bin.clone();
    }

    panic!(
        "unable to locate ccstats binary; checked: {}",
        candidates
            .iter()
            .map(|path| path.display().to_string())
            .collect::<Vec<_>>()
            .join(", ")
    );
}

fn run_ccstats(args: &[&str], envs: &[(&str, &Path)]) -> (bool, Vec<u8>, Vec<u8>) {
    let mut cmd = Command::new(resolve_ccstats_binary());
    cmd.args(args);
    for (k, v) in envs {
        cmd.env(k, v);
    }
    let output = cmd.output().expect("run ccstats");
    (output.status.success(), output.stdout, output.stderr)
}

#[test]
fn codex_daily_json_reads_session_data() {
    let root = unique_temp_dir("codex-daily");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("test-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"turn_context","payload":{"model":"gpt-5"}}
{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":10,"total_tokens":140},"last_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":10,"total_tokens":140},"model":"gpt-5"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    // OpenAI output_tokens(30) includes reasoning_output_tokens(10).
    // After separating: non_cached_input=80, output=20, reasoning=10, cache_read=20 → total=130
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(130));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn source_flag_can_select_codex_without_subcommand() {
    let root = unique_temp_dir("source-flag-codex");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("flag-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"model":"gpt-5"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "--source",
            "codex",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(15));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn source_all_daily_json_merges_registered_sources() {
    let root = unique_temp_dir("source-all-daily");
    let codex_home = root.join("codex-home");
    let claude_session = root.join(".claude/projects/myapp/claude-session.jsonl");
    let codex_session = codex_home.join("sessions").join("codex-session.jsonl");

    write_file(
        &claude_session,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );
    write_file(
        &codex_session,
        r#"{"timestamp":"2026-02-06T11:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"model":"gpt-5"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "--source",
            "all",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root), ("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    assert_eq!(arr[0]["input_tokens"].as_i64(), Some(110));
    assert_eq!(arr[0]["output_tokens"].as_i64(), Some(55));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(165));

    let models = arr[0]["models"].as_array().expect("models");
    assert_eq!(models.len(), 2);
    assert!(
        models
            .iter()
            .any(|model| model.as_str() == Some("3-5-sonnet"))
    );
    assert!(models.iter().any(|model| model.as_str() == Some("gpt-5")));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn source_flag_can_select_cursor_without_subcommand() {
    let root = unique_temp_dir("source-flag-cursor");
    let cursor_home = root.join("cursor-user");
    write_cursor_state_db(&cursor_home.join("globalStorage").join("state.vscdb"));

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "--source",
            "cursor",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CURSOR_HOME", &cursor_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    assert_eq!(arr[0]["input_tokens"].as_i64(), Some(100));
    assert_eq!(arr[0]["output_tokens"].as_i64(), Some(40));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(140));
    assert_eq!(
        arr[0]["models"].as_array().unwrap()[0].as_str(),
        Some("claude-4-sonnet")
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn monthly_budget_json_includes_forecast() {
    let root = unique_temp_dir("monthly-budget-json");
    let session_file = root.join(".claude/projects/myapp/session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2025-02-10T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":1000000,"output_tokens":100000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "monthly",
            "-j",
            "-O",
            "--timezone",
            "UTC",
            "--since",
            "2025-02-01",
            "--until",
            "2025-02-10",
            "--monthly-budget",
            "10",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["month"].as_str(), Some("2025-02"));

    let budget = &arr[0]["budget"];
    assert_eq!(budget["days_elapsed"].as_i64(), Some(10));
    assert_eq!(budget["days_in_month"].as_i64(), Some(28));
    assert_eq!(budget["status"].as_str(), Some("over_budget"));
    assert!((budget["spent"].as_f64().unwrap() - 4.5).abs() < 0.001);
    assert!((budget["projected"].as_f64().unwrap() - 12.6).abs() < 0.001);
    assert!((budget["projected_pct"].as_f64().unwrap() - 126.0).abs() < 0.001);

    let _ = fs::remove_dir_all(root);
}

#[test]
fn monthly_budget_csv_includes_budget_columns() {
    let root = unique_temp_dir("monthly-budget-csv");
    let session_file = root.join(".claude/projects/myapp/session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2025-02-10T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":1000000,"output_tokens":100000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "monthly",
            "--csv",
            "-O",
            "--timezone",
            "UTC",
            "--since",
            "2025-02-01",
            "--until",
            "2025-02-10",
            "--monthly-budget",
            "10",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let output = String::from_utf8_lossy(&stdout);
    let mut lines = output.lines();
    let header = lines.next().expect("header");
    let row = lines.next().expect("row");
    assert!(header.contains("budget_projected"));
    assert!(header.contains("budget_status"));
    assert!(row.contains("over_budget"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn monthly_budget_rejects_hidden_costs() {
    let root = unique_temp_dir("monthly-budget-no-cost");
    let (ok, _stdout, stderr) = run_ccstats(
        &["monthly", "-O", "--monthly-budget", "10", "--no-cost"],
        &[("HOME", &root)],
    );
    assert!(!ok, "expected hidden-cost failure");
    let stderr = String::from_utf8_lossy(&stderr);
    assert!(stderr.contains("--monthly-budget requires cost display"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_subcommand_conflicts_with_different_source_flag() {
    let root = unique_temp_dir("source-flag-conflict");
    let (ok, _stdout, stderr) = run_ccstats(
        &["codex", "daily", "--source", "claude", "-O", "--no-cost"],
        &[("HOME", &root)],
    );
    assert!(!ok, "expected conflict failure");
    let stderr = String::from_utf8_lossy(&stderr);
    assert!(stderr.contains("conflicts with --source"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_subcommand_conflicts_with_source_all() {
    let root = unique_temp_dir("source-all-conflict");
    let (ok, _stdout, stderr) = run_ccstats(
        &["codex", "daily", "--source", "all", "-O", "--no-cost"],
        &[("HOME", &root)],
    );
    assert!(!ok, "expected conflict failure");
    let stderr = String::from_utf8_lossy(&stderr);
    assert!(stderr.contains("conflicts with --source all"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn malformed_records_are_reported_without_debug_flag() {
    let root = unique_temp_dir("malformed-record-warning");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("malformed.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0,"total_tokens":15},"model":"gpt-5"}}}
{"timestamp":"not-json"
"#,
    );

    let (ok, _stdout, stderr) = run_ccstats(
        &[
            "daily",
            "--source",
            "codex",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));
    let stderr = String::from_utf8_lossy(&stderr);
    assert!(stderr.contains("malformed records"), "stderr: {stderr}");

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_reasoning_tokens_not_double_counted() {
    let root = unique_temp_dir("codex-reasoning");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("reasoning-session.jsonl");
    // OpenAI's output_tokens INCLUDES reasoning_output_tokens as a subset.
    // output_tokens=500 with reasoning_output_tokens=200 means 300 non-reasoning + 200 reasoning.
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"model":"gpt-5.2-codex"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    // output_tokens should be non-reasoning only: 500 - 200 = 300
    assert_eq!(arr[0]["output_tokens"].as_i64(), Some(300));
    // reasoning_tokens should be the separated reasoning portion
    assert_eq!(arr[0]["reasoning_tokens"].as_i64(), Some(200));
    // total = input(1000) + output(300) + reasoning(200) + cache(0) = 1500, no double-counting
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(1500));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_session_json_orders_by_actual_timestamp() {
    let root = unique_temp_dir("codex-session-order");
    let codex_home = root.join("codex-home");
    let session_a = codex_home.join("sessions").join("a.jsonl");
    let session_b = codex_home.join("sessions").join("b.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T23:00:00+08:00","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"last_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"model":"gpt-5"}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T16:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"last_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"model":"gpt-5"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "session",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
            "--order",
            "desc",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 2);
    assert_eq!(arr[0]["session_id"].as_str(), Some("b")); // 16:00Z, newer
    assert_eq!(arr[1]["session_id"].as_str(), Some("a")); // 15:00Z, older

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_session_csv_orders_by_actual_timestamp() {
    let root = unique_temp_dir("codex-session-csv-order");
    let codex_home = root.join("codex-home");
    let session_a = codex_home.join("sessions").join("a.jsonl");
    let session_b = codex_home.join("sessions").join("b.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T23:00:00+08:00","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"last_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"model":"gpt-5"}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T16:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"last_token_usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":2},"model":"gpt-5"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "session",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
            "--order",
            "desc",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let csv = String::from_utf8(stdout).expect("utf8 csv");
    let mut lines = csv.lines();
    let _header = lines.next().expect("csv header");
    let first = lines.next().expect("first row");
    let second = lines.next().expect("second row");

    assert!(first.starts_with("b,"), "expected b first, got: {first}");
    assert!(second.starts_with("a,"), "expected a second, got: {second}");

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_session_json_includes_reasoning_tokens() {
    let root = unique_temp_dir("codex-session-reasoning");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("reasoning-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"model":"gpt-5.2-codex"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "session",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["reasoning_tokens"].as_i64(), Some(200));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn codex_session_csv_includes_reasoning_and_cache_tokens() {
    let root = unique_temp_dir("codex-session-reasoning-csv");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("reasoning-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":500,"reasoning_output_tokens":200,"total_tokens":1500},"model":"gpt-5.2-codex"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "session",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let csv = String::from_utf8(stdout).expect("utf8 csv");
    let mut lines = csv.lines();
    let header = lines.next().expect("header");
    let row = lines.next().expect("row");
    assert_eq!(
        header,
        "session_id,project_path,first_timestamp,last_timestamp,input_tokens,output_tokens,reasoning_tokens,cache_creation_tokens,cache_read_tokens,total_tokens"
    );
    assert_eq!(
        row,
        "reasoning-session,,2026-02-06T10:00:00Z,2026-02-06T10:00:00Z,900,300,200,0,100,1500"
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn strict_pricing_sets_unknown_cost_to_null() {
    let root = unique_temp_dir("strict-pricing");
    let codex_home = root.join("codex-home");
    let session_file = codex_home.join("sessions").join("strict-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T11:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":10,"reasoning_output_tokens":0,"total_tokens":60},"last_token_usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":10,"reasoning_output_tokens":0,"total_tokens":60},"model":"mystery-model"}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "codex",
            "daily",
            "-j",
            "-O",
            "--strict-pricing",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("CODEX_HOME", &codex_home)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert!(arr[0]["cost"].is_null());

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_project_json_aggregates_sessions() {
    let root = unique_temp_dir("claude-project");
    // Two sessions in the same project, one session in a different project
    let session_a = root.join(".claude/projects/myapp/session-a.jsonl");
    let session_b = root.join(".claude/projects/myapp/session-b.jsonl");
    let session_c = root.join(".claude/projects/other-project/session-c.jsonl");

    // Session A: sonnet, 100 input + 50 output + 10 cache_creation + 20 cache_read = 180 total
    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}
"#,
    );
    // Session B: opus, 200 input + 80 output = 280 total
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","message":{"id":"msg_2","model":"claude-4-opus-20250514","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":80,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );
    // Session C: sonnet, 50 input + 25 output = 75 total
    write_file(
        &session_c,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_3","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":50,"output_tokens":25,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "project",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 2, "should have 2 projects");

    // Find each project by name (sort order depends on cost, which is 0 with --no-cost)
    let myapp = arr
        .iter()
        .find(|p| p["project"].as_str() == Some("myapp"))
        .expect("myapp project");
    let other = arr
        .iter()
        .find(|p| p["project"].as_str() == Some("other-project"))
        .expect("other-project");

    assert_eq!(myapp["session_count"].as_i64(), Some(2));
    assert_eq!(myapp["total_tokens"].as_i64(), Some(460));
    // Models should be sorted alphabetically
    let models: Vec<&str> = myapp["models"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v.as_str().unwrap())
        .collect();
    assert_eq!(models.len(), 2);
    assert!(models[0] < models[1], "models should be sorted: {models:?}");

    assert_eq!(other["session_count"].as_i64(), Some(1));
    assert_eq!(other["total_tokens"].as_i64(), Some(75));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_project_json_ignores_sidechain_subagent_logs() {
    let root = unique_temp_dir("claude-project-subagents");
    let session_a = root.join(".claude/projects/myapp/session-a.jsonl");
    let session_b = root.join(".claude/projects/myapp/subagents/agent-a.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","isSidechain":true,"message":{"id":"msg_2","model":"gpt-5.2-codex","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":80}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "project",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["project"].as_str(), Some("myapp"));
    assert_eq!(arr[0]["project_path"].as_str(), Some("myapp"));
    assert_eq!(arr[0]["session_count"].as_i64(), Some(1));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(150));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_session_json_keeps_same_file_stem_from_different_projects_separate() {
    let root = unique_temp_dir("claude-session-separate-stems");
    let session_a = root.join(".claude/projects/projA/shared.jsonl");
    let session_b = root.join(".claude/projects/projB/shared.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_a","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":20}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","message":{"id":"msg_b","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":30}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "session",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
            "--order",
            "desc",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 2);
    assert_eq!(arr[0]["session_id"].as_str(), Some("shared"));
    assert_eq!(arr[0]["project_path"].as_str(), Some("projB"));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(230));
    assert_eq!(arr[1]["session_id"].as_str(), Some("shared"));
    assert_eq!(arr[1]["project_path"].as_str(), Some("projA"));
    assert_eq!(arr[1]["total_tokens"].as_i64(), Some(120));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn invalid_currency_falls_back_to_usd_costs() {
    let root = unique_temp_dir("invalid-currency-fallback");
    let claude_file = root.join(".claude/projects/myproject/session-a.jsonl");
    write_file(
        &claude_file,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-j",
            "-O",
            "--timezone",
            "UTC",
            "--currency",
            "ZZZ",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let stderr = String::from_utf8(stderr).expect("utf8 stderr");
    assert!(stderr.contains("Warning: failed to load exchange rate"));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert!(arr[0]["cost"].as_f64().is_some());

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_blocks_json_groups_by_5h_window() {
    let root = unique_temp_dir("claude-blocks");
    let session = root.join(".claude/projects/myapp/session-blocks.jsonl");

    // Entry at 10:00 UTC → block 10:00-15:00
    // Entry at 14:30 UTC → same block 10:00-15:00
    // Entry at 15:00 UTC → block 15:00-20:00
    write_file(
        &session,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_a","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
{"timestamp":"2026-02-06T14:30:00Z","message":{"id":"msg_b","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
{"timestamp":"2026-02-06T15:00:00Z","message":{"id":"msg_c","model":"claude-4-opus-20250514","stop_reason":"end_turn","usage":{"input_tokens":300,"output_tokens":150,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "blocks",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 2, "should have 2 blocks");

    // Default sort is asc by block_start
    let block_10 = &arr[0];
    assert!(
        block_10["block_start"].as_str().unwrap().contains("10:00"),
        "first block should start at 10:00"
    );
    assert_eq!(block_10["block_end"].as_str(), Some("15:00"));
    // 100+50 + 200+100 = 450
    assert_eq!(block_10["total_tokens"].as_i64(), Some(450));

    let block_15 = &arr[1];
    assert!(
        block_15["block_start"].as_str().unwrap().contains("15:00"),
        "second block should start at 15:00"
    );
    assert_eq!(block_15["block_end"].as_str(), Some("20:00"));
    // 300+150 = 450
    assert_eq!(block_15["total_tokens"].as_i64(), Some(450));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_dedup_keeps_completed_message() {
    let root = unique_temp_dir("claude-dedup");
    let session = root.join(".claude/projects/myapp/session-dedup.jsonl");

    // Same message ID: first without stop_reason (streaming), second with stop_reason (completed)
    // Dedup should keep the completed one with accurate token counts
    write_file(
        &session,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_dup","model":"claude-3-5-sonnet-20241022","usage":{"input_tokens":50,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
{"timestamp":"2026-02-06T10:00:01Z","message":{"id":"msg_dup","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    // Should use the completed message's tokens (100+50=150), not the streaming one (50+10=60)
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(150));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_dedup_keeps_same_message_id_from_different_files() {
    let root = unique_temp_dir("claude-dedup-scope");
    let file_a = root.join(".claude/projects/projA/a.jsonl");
    let file_b = root.join(".claude/projects/projB/b.jsonl");

    write_file(
        &file_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_dup","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50}}}
"#,
    );
    write_file(
        &file_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","message":{"id":"msg_dup","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":80}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(430));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_daily_json_reads_home_projects() {
    let root = unique_temp_dir("claude-daily");
    let claude_file = root.join(".claude/projects/myproject/session-a.jsonl");
    write_file(
        &claude_file,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_1","model":"anthropic.claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(180));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_daily_ignores_sidechains_and_subagent_logs() {
    let root = unique_temp_dir("claude-ignore-sidechains");
    let claude_file = root.join(".claude/projects/myproject/session-a.jsonl");
    let subagent_file = root.join(".claude/projects/myproject/subagents/agent-a.jsonl");

    write_file(
        &claude_file,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}
{"timestamp":"2026-02-06T12:30:00Z","isSidechain":true,"message":{"id":"msg_2","model":"gpt-5.3-codex","stop_reason":"end_turn","usage":{"input_tokens":500,"output_tokens":200,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );
    write_file(
        &subagent_file,
        r#"{"timestamp":"2026-02-06T14:00:00Z","isSidechain":true,"message":{"id":"msg_sub","model":"gpt-5.2-codex","stop_reason":"end_turn","usage":{"input_tokens":700,"output_tokens":300,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-j",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let json: Value = serde_json::from_slice(&stdout).expect("json");
    let arr = json.as_array().expect("array output");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["date"].as_str(), Some("2026-02-06"));
    assert_eq!(arr[0]["total_tokens"].as_i64(), Some(180));
    let models = arr[0]["models"].as_array().expect("models array");
    assert_eq!(models.len(), 1);
    assert_eq!(models[0].as_str(), Some("3-5-sonnet"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_daily_csv_outputs_correct_format() {
    let root = unique_temp_dir("claude-csv-daily");
    let claude_file = root.join(".claude/projects/myproject/session-a.jsonl");
    write_file(
        &claude_file,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "daily",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let output = String::from_utf8(stdout).expect("utf8");
    let lines: Vec<&str> = output.lines().collect();
    assert_eq!(lines.len(), 2, "header + 1 data row");
    assert_eq!(
        lines[0],
        "date,input_tokens,output_tokens,reasoning_tokens,cache_creation_tokens,cache_read_tokens,total_tokens"
    );
    // input=100, output=50, reasoning=0, cache_creation=10, cache_read=20, total=180
    assert_eq!(lines[1], "2026-02-06,100,50,0,10,20,180");

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_session_csv_outputs_correct_format() {
    let root = unique_temp_dir("claude-csv-session");
    let session_a = root.join(".claude/projects/myapp/session-a.jsonl");
    let session_b = root.join(".claude/projects/myapp/session-b.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","message":{"id":"msg_2","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":80,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "session",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let output = String::from_utf8(stdout).expect("utf8");
    let lines: Vec<&str> = output.lines().collect();
    assert_eq!(lines.len(), 3, "header + 2 sessions");
    assert_eq!(
        lines[0],
        "session_id,project_path,first_timestamp,last_timestamp,input_tokens,output_tokens,reasoning_tokens,cache_creation_tokens,cache_read_tokens,total_tokens"
    );
    // Sessions sorted by last_timestamp asc: session-a (10:00) then session-b (11:00)
    assert!(lines[1].starts_with("session-a,"));
    assert!(lines[1].ends_with(",100,50,0,0,0,150"));
    assert!(lines[2].starts_with("session-b,"));
    assert!(lines[2].ends_with(",200,80,0,0,0,280"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_project_csv_outputs_correct_format() {
    let root = unique_temp_dir("claude-csv-project");
    let session_a = root.join(".claude/projects/myapp/session-a.jsonl");
    let session_b = root.join(".claude/projects/other-project/session-b.jsonl");

    write_file(
        &session_a,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );
    write_file(
        &session_b,
        r#"{"timestamp":"2026-02-06T11:00:00Z","message":{"id":"msg_2","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":80,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "project",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let output = String::from_utf8(stdout).expect("utf8");
    let lines: Vec<&str> = output.lines().collect();
    assert_eq!(lines.len(), 3, "header + 2 projects");
    assert_eq!(
        lines[0],
        "project_name,project_path,sessions,input_tokens,output_tokens,total_tokens"
    );
    // With --no-cost, all costs are 0.0 so order is undefined — find by name
    let myapp_line = lines
        .iter()
        .find(|l| l.starts_with("myapp,"))
        .expect("myapp row");
    assert!(myapp_line.ends_with(",1,100,50,150"));
    let other_line = lines
        .iter()
        .find(|l| l.starts_with("other-project,"))
        .expect("other-project row");
    assert!(other_line.ends_with(",1,200,80,280"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn claude_blocks_csv_outputs_correct_format() {
    let root = unique_temp_dir("claude-csv-blocks");
    let session = root.join(".claude/projects/myapp/session-blocks.jsonl");

    // Entry at 10:00 UTC → block 10:00-15:00
    // Entry at 15:00 UTC → block 15:00-20:00
    write_file(
        &session,
        r#"{"timestamp":"2026-02-06T10:00:00Z","message":{"id":"msg_a","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}
{"timestamp":"2026-02-06T15:00:00Z","message":{"id":"msg_b","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":300,"output_tokens":150,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, stdout, stderr) = run_ccstats(
        &[
            "blocks",
            "--csv",
            "-O",
            "--no-cost",
            "--timezone",
            "UTC",
            "--since",
            "2026-02-06",
            "--until",
            "2026-02-06",
        ],
        &[("HOME", &root)],
    );
    assert!(ok, "stderr: {}", String::from_utf8_lossy(&stderr));

    let output = String::from_utf8(stdout).expect("utf8");
    let lines: Vec<&str> = output.lines().collect();
    assert_eq!(lines.len(), 3, "header + 2 blocks");
    assert_eq!(
        lines[0],
        "block_start,block_end,input_tokens,output_tokens,cache_creation_tokens,cache_read_tokens,total_tokens"
    );
    // Block 1: 10:00-15:00, input=100, output=50, cache_creation=10, cache_read=20, total=180
    assert!(lines[1].contains("10:00"));
    assert!(lines[1].ends_with(",100,50,10,20,180"));
    // Block 2: 15:00-20:00, input=300, output=150, cache_creation=0, cache_read=0, total=450
    assert!(lines[2].contains("15:00"));
    assert!(lines[2].ends_with(",300,150,0,0,450"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn since_after_until_exits_with_error() {
    let root = unique_temp_dir("date-range");
    let claude_file = root.join(".claude/projects/myproject/session.jsonl");
    write_file(
        &claude_file,
        r#"{"timestamp":"2026-02-06T12:00:00Z","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
"#,
    );

    let (ok, _stdout, stderr) = run_ccstats(
        &[
            "daily",
            "-O",
            "--since",
            "2026-03-01",
            "--until",
            "2026-01-01",
        ],
        &[("HOME", &root)],
    );
    assert!(!ok, "should fail when --since is after --until");
    let err = String::from_utf8_lossy(&stderr);
    assert!(
        err.contains("--since") && err.contains("--until"),
        "error should mention both flags: {err}"
    );

    let _ = fs::remove_dir_all(root);
}
