//! Integration tests for the tidalctl binary.
//!
//! Tests run the actual binary as a subprocess via `std::process::Command`.

#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used)]

use std::process::Command;

use tidaldb::{
    db::temp::TempTidalHome,
    wal::{SignalEvent, WalConfig, WalHandle},
};

fn tidalctl_bin() -> Command {
    Command::new(env!("CARGO_BIN_EXE_tidalctl"))
}

/// Create a `TempTidalHome` with WAL segments and a checkpoint.
/// Returns the home so it stays alive (not dropped) for the test.
fn home_with_wal_data() -> TempTidalHome {
    let home = TempTidalHome::new().expect("create temp home");
    let paths = home.paths();
    paths.ensure_all().expect("create subdirs");

    let config = WalConfig {
        dir: home.path().to_path_buf(),
        ..WalConfig::default()
    };

    let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");

    for i in 1..=5 {
        let event = SignalEvent {
            entity_id: i,
            signal_type: 1,
            weight: 1.0,
            timestamp_nanos: i * 1_000_000_000,
        };
        let _seq = handle.append(event).expect("append event");
    }

    handle.checkpoint(3).expect("checkpoint");
    handle.shutdown().expect("shutdown WAL");

    home
}

/// Create a `TempTidalHome` with WAL segments but NO checkpoint, so a
/// never-checkpointed database can be exercised (`checkpoint_ts` == 0).
fn home_with_wal_no_checkpoint() -> TempTidalHome {
    let home = TempTidalHome::new().expect("create temp home");
    let paths = home.paths();
    paths.ensure_all().expect("create subdirs");

    let config = WalConfig {
        dir: home.path().to_path_buf(),
        ..WalConfig::default()
    };

    let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");

    for i in 1..=5 {
        let event = SignalEvent {
            entity_id: i,
            signal_type: 1,
            weight: 1.0,
            timestamp_nanos: i * 1_000_000_000,
        };
        let _seq = handle.append(event).expect("append event");
    }

    // Deliberately NO `handle.checkpoint(...)` -> no checkpoint.meta on disk.
    handle.shutdown().expect("shutdown WAL");

    home
}

/// Corrupt the WAL `checkpoint.meta` so `CheckpointManager::read` returns a
/// `Corruption` error. Both `status` (via `gather_wal_state`) and `diagnostics`
/// (via `diagnose_wal`) then fail their WAL read, which must surface as the
/// degraded exit code 2.
fn corrupt_wal_checkpoint(home: &TempTidalHome) {
    let checkpoint = home.paths().wal_dir().join("checkpoint.meta");
    // An unparseable `seq=` value yields WalError::Corruption (not NotFound).
    std::fs::write(&checkpoint, "seq=not_a_number\nts=123\n").expect("overwrite checkpoint.meta");
}

#[test]
fn status_with_wal_segments_exits_0() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args(["status", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl");

    assert!(
        output.status.success(),
        "exit code: {}, stderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert_eq!(json["status"], "ok");
    assert!(
        json["wal"]["segments"].as_u64().unwrap_or(0) > 0,
        "should have segments: {stdout}"
    );
    assert!(
        json["wal"]["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
        "should have checkpoint: {stdout}"
    );
    assert!(json["version"].is_string());
    assert!(json["build_hash"].is_string());
    assert!(json["dirs"]["base"].is_string());
}

#[test]
fn status_empty_dir_shows_empty() {
    let home = TempTidalHome::new().expect("create temp home");
    let paths = home.paths();
    paths.ensure_all().expect("create subdirs");

    let output = tidalctl_bin()
        .args(["status", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert_eq!(json["status"], "empty");
}

#[test]
fn status_nonexistent_path_exits_0_with_empty() {
    // When the WAL dir doesn't exist, gather_wal_state returns segments=0
    // which maps to "empty" status. The base dir may or may not exist.
    let output = tidalctl_bin()
        .args(["status", "--path", "/nonexistent/path/that/does/not/exist"])
        .output()
        .expect("run tidalctl");

    let stdout = String::from_utf8(output.stdout).expect("utf8");
    // This should still be valid JSON regardless of status
    if output.status.success() {
        let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
        assert!(
            json["status"] == "empty" || json["status"] == "error",
            "status should be empty or error: {stdout}"
        );
    } else {
        let stderr = String::from_utf8(output.stderr).expect("utf8");
        let json: serde_json::Value = serde_json::from_str(&stderr).expect("parse error JSON");
        assert!(json["error"].is_string());
    }
}

#[test]
fn status_corrupt_wal_exits_2_and_reports_error() {
    // A corrupt checkpoint.meta makes gather_wal_state fail. `status` must then
    // exit 2 (degraded/unreadable), mirroring `diagnostics`, NOT 0 — otherwise
    // `tidalctl status --path X && deploy` would treat a corrupt WAL as success.
    let home = home_with_wal_data();
    corrupt_wal_checkpoint(&home);

    let output = tidalctl_bin()
        .args(["status", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl");

    assert_eq!(
        output.status.code(),
        Some(2),
        "corrupt WAL must exit 2, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    // The body is still valid JSON and carries the error status + envelope.
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert_eq!(json["status"], "error", "status must be error: {stdout}");
    assert!(
        json["wal"]["error"].is_string(),
        "wal error envelope must be present: {stdout}"
    );
}

#[test]
fn paths_exits_0_with_all_dirs() {
    let home = TempTidalHome::new().expect("create temp home");
    let paths = home.paths();
    paths.ensure_all().expect("create subdirs");

    let output = tidalctl_bin()
        .args(["paths", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    // Check all six directory paths are present
    assert!(json["base"].is_string(), "missing base: {stdout}");
    assert!(json["wal"].is_string(), "missing wal: {stdout}");
    assert!(json["items"].is_string(), "missing items: {stdout}");
    assert!(json["users"].is_string(), "missing users: {stdout}");
    assert!(json["creators"].is_string(), "missing creators: {stdout}");
    assert!(json["cache"].is_string(), "missing cache: {stdout}");

    // Check exists map
    assert!(json["exists"]["base"].is_boolean());
    assert!(json["exists"]["wal"].is_boolean());
    assert_eq!(json["exists"]["base"], true);
    assert_eq!(json["exists"]["wal"], true);
}

#[test]
fn paths_nonexistent_dir_shows_false_exists() {
    let output = tidalctl_bin()
        .args(["paths", "--path", "/nonexistent/does/not/exist"])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert_eq!(json["exists"]["base"], false);
    assert_eq!(json["exists"]["wal"], false);
}

#[test]
fn pretty_flag_produces_indented_json() {
    let home = TempTidalHome::new().expect("create temp home");

    let output = tidalctl_bin()
        .args(["paths", "--path", home.path().to_str().unwrap(), "--pretty"])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    // Pretty-printed JSON should have newlines and indentation
    assert!(
        stdout.contains('\n'),
        "pretty output should have newlines: {stdout}"
    );
    assert!(
        stdout.contains("  "),
        "pretty output should have indentation: {stdout}"
    );
    // Should still be valid JSON
    let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
}

#[test]
fn bad_command_exits_1() {
    let output = tidalctl_bin()
        .args(["nonexistent_command", "--path", "/tmp"])
        .output()
        .expect("run tidalctl");

    assert!(!output.status.success(), "should exit 1 for bad command");
    let stderr = String::from_utf8(output.stderr).expect("utf8");
    assert!(
        stderr.contains("error"),
        "stderr should contain error: {stderr}"
    );
}

#[test]
fn no_args_exits_1() {
    let output = tidalctl_bin().output().expect("run tidalctl");

    assert!(!output.status.success(), "should exit 1 with no args");
}

#[test]
fn missing_path_flag_exits_1() {
    let output = tidalctl_bin()
        .args(["status"])
        .output()
        .expect("run tidalctl");

    assert!(!output.status.success(), "should exit 1 without --path");
    let stderr = String::from_utf8(output.stderr).expect("utf8");
    assert!(
        stderr.contains("error"),
        "stderr should contain error: {stderr}"
    );
}

// ---------------------------------------------------------------------------
// recover --verify-only tests
// ---------------------------------------------------------------------------

#[test]
fn recover_verify_only_with_wal_data_json() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            home.path().to_str().unwrap(),
        ])
        .output()
        .expect("run tidalctl");

    assert!(
        output.status.success(),
        "exit code: {}, stderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert!(
        json["total_events"].as_u64().unwrap_or(0) > 0,
        "should have total_events: {stdout}"
    );
    assert!(
        json["segment_count"].as_u64().unwrap_or(0) > 0,
        "should have segments: {stdout}"
    );
    assert!(
        json["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
        "should have checkpoint: {stdout}"
    );
    assert!(
        json["estimated_recovery_secs"].as_f64().unwrap_or(0.0) > 0.0,
        "should have recovery time estimate: {stdout}"
    );
    assert!(
        json["segments"].is_array(),
        "should have segments array: {stdout}"
    );
}

#[test]
fn recover_verify_only_empty_dir() {
    let home = TempTidalHome::new().expect("create temp home");
    let paths = home.paths();
    paths.ensure_all().expect("create subdirs");

    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            home.path().to_str().unwrap(),
        ])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert_eq!(json["total_events"], 0);
    assert_eq!(json["segment_count"], 0);
    assert_eq!(json["inconsistency_count"], 0);
}

#[test]
fn recover_verify_only_pretty_output() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run tidalctl");

    assert!(
        output.status.success(),
        "exit code: {}, stderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("utf8");
    assert!(
        stdout.contains("WAL Diagnostic Report"),
        "should contain report header: {stdout}"
    );
    assert!(
        stdout.contains("Checkpoint:"),
        "should contain checkpoint section: {stdout}"
    );
    assert!(
        stdout.contains("Events:"),
        "should contain events section: {stdout}"
    );
    assert!(
        stdout.contains("Estimated Recovery Time:"),
        "should contain recovery estimate: {stdout}"
    );
    assert!(
        stdout.contains("Segment Inventory:"),
        "should contain segment table: {stdout}"
    );
}

#[test]
fn recover_without_verify_only_exits_1() {
    let home = TempTidalHome::new().expect("create temp home");

    let output = tidalctl_bin()
        .args(["recover", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl");

    assert!(
        !output.status.success(),
        "should exit 1 without --verify-only"
    );
    let stderr = String::from_utf8(output.stderr).expect("utf8");
    assert!(
        stderr.contains("verify-only"),
        "stderr should mention verify-only: {stderr}"
    );
}

#[test]
fn recover_nonexistent_path_returns_empty_report() {
    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            "/nonexistent/path/that/does/not/exist",
        ])
        .output()
        .expect("run tidalctl");

    // Should succeed with an empty report (no WAL dir = empty report).
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert_eq!(json["total_events"], 0);
    assert_eq!(json["segment_count"], 0);
}

// ---------------------------------------------------------------------------
// diagnostics tests
// ---------------------------------------------------------------------------

#[test]
fn diagnostics_json_output_valid() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");

    assert!(
        output.status.success(),
        "exit code: {}, stderr: {}",
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("valid utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");

    // Fields genuinely derivable offline from disk are real numbers.
    assert!(json["version"].is_string());
    assert!(json["wal_segments"].is_number());
    assert!(json["checkpoint_age_seconds"].is_number());
    assert!(json["tantivy_segments"].is_number());
    assert!(json["tantivy_indexed_docs"].is_number());
    assert!(json["checkpoint_wal_sequence"].is_number());
    assert!(json["wal_total_bytes"].is_number());

    // Fields that cannot be honestly derived offline MUST be JSON `null`, never
    // a fabricated `0` that an operator would misread as a real value.
    for field in [
        "usearch_directory_bytes",
        "sessions_active",
        "sessions_closed_total",
        "sessions_auto_closed_total",
        "degradation_level",
        "collection_count",
        "cohort_count",
    ] {
        assert!(
            json[field].is_null(),
            "{field} must be null offline, got {}: {stdout}",
            json[field]
        );
    }

    // The output is self-describing: every null field is documented with a reason.
    let unavailable = json["offline_unavailable"]
        .as_object()
        .expect("offline_unavailable should be an object");
    for field in [
        "usearch_directory_bytes",
        "sessions_active",
        "sessions_closed_total",
        "sessions_auto_closed_total",
        "degradation_level",
        "collection_count",
        "cohort_count",
    ] {
        assert!(
            unavailable
                .get(field)
                .and_then(serde_json::Value::as_str)
                .is_some(),
            "offline_unavailable should explain {field}: {stdout}"
        );
    }
}

#[test]
fn diagnostics_pretty_output_readable() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args([
            "diagnostics",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run tidalctl diagnostics");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("tidalDB Diagnostics"),
        "missing header: {stdout}"
    );
    assert!(stdout.contains("WAL"), "missing WAL section: {stdout}");
    assert!(
        stdout.contains("Checkpoint"),
        "missing Checkpoint section: {stdout}"
    );
    assert!(
        stdout.contains("Signal Ledger"),
        "missing Signal Ledger section: {stdout}"
    );
    assert!(
        stdout.contains("Text Index (Tantivy)"),
        "missing Text Index section: {stdout}"
    );
    assert!(
        stdout.contains("Vector Index (USearch)"),
        "missing Vector Index section: {stdout}"
    );
    assert!(
        stdout.contains("Sessions"),
        "missing Sessions section: {stdout}"
    );
    assert!(
        stdout.contains("Degradation"),
        "missing Degradation section: {stdout}"
    );

    // Runtime-only / locked-keyspace fields must be marked unavailable, never
    // printed as a fabricated "0".
    assert!(
        stdout.contains("not available offline"),
        "offline-unavailable fields must say so, not print a fabricated 0: {stdout}"
    );
    assert!(
        stdout.contains("lock"),
        "pretty output should explain the lock-free constraint: {stdout}"
    );
}

#[test]
fn diagnostics_missing_dir_exits_1() {
    let output = tidalctl_bin()
        .args([
            "diagnostics",
            "--path",
            "/nonexistent/tidaldb/diagnostics/path",
        ])
        .output()
        .expect("run tidalctl diagnostics");
    assert_eq!(output.status.code(), Some(1));
}

#[test]
fn diagnostics_no_checkpoint_reports_null_age() {
    // A never-checkpointed database has checkpoint_ts == 0. `checkpoint_age_secs`
    // is then None and MUST render as JSON `null`, never a fabricated `0` that
    // an operator reads as "checkpointed just now". The pretty path already says
    // "none"; the two surfaces must agree.
    let home = home_with_wal_no_checkpoint();

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert!(
        output.status.success(),
        "no-checkpoint WAL is not degraded; exit 0, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert!(
        json["checkpoint_age_seconds"].is_null(),
        "never-checkpointed DB must report null age, not 0: {stdout}"
    );
    assert_eq!(
        json["checkpoint_wal_sequence"], 0,
        "no checkpoint -> sequence 0: {stdout}"
    );

    // The pretty surface must agree: "Last checkpoint: none".
    let pretty = tidalctl_bin()
        .args([
            "diagnostics",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run tidalctl diagnostics pretty");
    assert!(pretty.status.success());
    let pretty_out = String::from_utf8(pretty.stdout).expect("utf8");
    assert!(
        pretty_out.contains("Last checkpoint: none"),
        "pretty must print 'none' for a never-checkpointed DB: {pretty_out}"
    );
}

#[test]
fn diagnostics_corrupt_wal_exits_2() {
    // A corrupt checkpoint.meta makes diagnose_wal fail. `diagnostics` must exit
    // 2 (degraded/unreadable), matching `status`.
    let home = home_with_wal_data();
    corrupt_wal_checkpoint(&home);

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert_eq!(
        output.status.code(),
        Some(2),
        "corrupt WAL must exit 2 for diagnostics, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    // Body is still valid JSON (the command renders what it could gather).
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
}

#[test]
fn diagnostics_unreadable_text_index_exits_2_and_reports_null() {
    // A `text_index/` directory that exists but is NOT a valid Tantivy index
    // (corruption / partial write) makes read_stats_from_dir return None.
    // diagnostics must distinguish this from a healthy-empty (absent) index:
    // exit 2 and emit `tantivy_segments: null`, never a fabricated 0 that is
    // byte-identical to a healthy empty index.
    let home = home_with_wal_data();
    let text_dir = home.path().join("text_index");
    std::fs::create_dir_all(&text_dir).expect("create text_index dir");
    // A bogus file makes the directory present but not openable as a Tantivy
    // index.
    std::fs::write(text_dir.join("meta.json"), b"not a tantivy index")
        .expect("write bogus index file");

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert_eq!(
        output.status.code(),
        Some(2),
        "unreadable text index must exit 2, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert!(
        json["tantivy_segments"].is_null(),
        "unreadable index must report null segments, not 0: {stdout}"
    );
    assert!(
        json["tantivy_indexed_docs"].is_null(),
        "unreadable index must report null docs, not 0: {stdout}"
    );

    // The pretty surface must say "not readable", not print a fabricated 0.
    let pretty = tidalctl_bin()
        .args([
            "diagnostics",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run tidalctl diagnostics pretty");
    assert_eq!(pretty.status.code(), Some(2));
    let pretty_out = String::from_utf8(pretty.stdout).expect("utf8");
    assert!(
        pretty_out.contains("not readable"),
        "pretty must mark an unreadable index 'not readable': {pretty_out}"
    );
}

#[test]
fn diagnostics_absent_text_index_is_healthy_zero() {
    // No text_index/ directory at all is a healthy empty store, NOT degraded:
    // exit 0 and report 0 segments/docs (a real number, not null).
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert!(
        output.status.success(),
        "absent text index is healthy-empty; exit 0, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert_eq!(
        json["tantivy_segments"], 0,
        "absent index is 0 segments (healthy empty): {stdout}"
    );
    assert_eq!(
        json["tantivy_indexed_docs"], 0,
        "absent index is 0 docs (healthy empty): {stdout}"
    );
}

// ── scope-stats (M9p1) ──────────────────────────────────────────────────────

/// Create a WAL with a mix of scoped events: 3 local, 2 community, 1 session.
fn home_with_scoped_wal_data() -> TempTidalHome {
    use tidaldb::{governance::SignalScope, wal::format::EventRecord};

    let home = TempTidalHome::new().expect("create temp home");
    home.paths().ensure_all().expect("create subdirs");

    let config = WalConfig {
        dir: home.path().to_path_buf(),
        ..WalConfig::default()
    };
    let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
    let sender = handle.sender();

    // 3 local signals (default path).
    for i in 1..=3 {
        handle
            .append(SignalEvent {
                entity_id: i,
                signal_type: 1,
                weight: 1.0,
                timestamp_nanos: i * 1_000_000_000,
            })
            .expect("append local");
    }
    // 2 community signals.
    let community = SignalScope::Community(tidaldb::governance::CommunityId(1)).discriminant();
    for i in 10..=11 {
        sender
            .append_record(EventRecord::scoped(
                i,
                1,
                1.0,
                i * 1_000_000_000,
                community,
                0,
                1,
                0,
            ))
            .expect("append community");
    }
    // 1 session signal.
    sender
        .append_record(EventRecord::scoped(
            20,
            1,
            1.0,
            20_000_000_000,
            SignalScope::Session.discriminant(),
            0,
            0,
            0,
        ))
        .expect("append session");

    handle.checkpoint(0).expect("checkpoint");
    handle.shutdown().expect("shutdown WAL");
    home
}

#[test]
fn scope_stats_tallies_events_by_scope() {
    let home = home_with_scoped_wal_data();
    let output = tidalctl_bin()
        .args(["scope-stats", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl scope-stats");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    let stats = &json["scope_stats"];
    assert_eq!(stats["total_events"], 6, "6 total events: {stdout}");
    assert_eq!(stats["by_scope"]["local"], 3);
    assert_eq!(stats["by_scope"]["community"], 2);
    assert_eq!(stats["by_scope"]["session"], 1);
    assert_eq!(stats["by_scope"]["agent"], 0);
    // share-eligible = everything non-local = 2 community + 1 session.
    assert_eq!(stats["share_eligible"], 3, "share-eligible count: {stdout}");
}

#[test]
fn scope_stats_empty_wal_is_all_zero() {
    let home = TempTidalHome::new().expect("create temp home");
    home.paths().ensure_all().expect("create subdirs");
    let output = tidalctl_bin()
        .args(["scope-stats", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl scope-stats");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    assert_eq!(json["scope_stats"]["total_events"], 0);
    assert_eq!(json["scope_stats"]["share_eligible"], 0);
}

// ── JSON escaping correctness (json_escape control-char hole) ────────────────

/// A path containing JSON control characters (tab, newline, carriage return,
/// NUL) must still produce VALID JSON on stdout — the old hand-rolled
/// `json_escape` only escaped `\` and `"`, emitting raw control chars that
/// strict parsers reject. `serde_json` now owns escaping, so the value must both
/// parse and round-trip byte-for-byte.
#[test]
fn paths_with_control_chars_emits_valid_json() {
    // A directory path containing a tab, newline, and carriage return. (A NUL
    // byte cannot survive an argv element on Unix, so the OS would reject it
    // before reaching us; serde handles NUL in-process at the serializer layer
    // either way.) We never create the path on disk; `paths` simply echoes the
    // resolved path strings, so the exact bytes flow through the JSON serializer
    // regardless of existence.
    let weird = "/tmp/tab\there/new\nline/cr\rret/control\u{1}byte";

    let output = tidalctl_bin()
        .args(["paths", "--path", weird])
        .output()
        .expect("run tidalctl");

    assert!(output.status.success(), "paths should exit 0");
    let stdout = String::from_utf8(output.stdout).expect("utf8");

    // The whole document must be a single valid JSON object on one line: a raw
    // newline in the value would either split the line or make this fail.
    assert_eq!(
        stdout.lines().count(),
        1,
        "compact JSON must be exactly one physical line: {stdout:?}"
    );
    let json: serde_json::Value =
        serde_json::from_str(&stdout).expect("control-char path must still be valid JSON");

    // The control characters must survive a round-trip exactly (serde escapes
    // them on the wire and un-escapes on parse).
    assert_eq!(
        json["base"].as_str().expect("base is a string"),
        weird,
        "control chars must round-trip byte-for-byte: {stdout:?}"
    );
}

/// The error envelope path (`CliError::to_json`) must also escape control
/// characters: an unknown-flag message that embeds user-supplied bytes with a
/// newline must yield parseable JSON on stderr, not a torn record.
#[test]
fn error_envelope_with_control_chars_is_valid_json() {
    let output = tidalctl_bin()
        .args(["status", "--path", "/tmp", "--bad\nflag"])
        .output()
        .expect("run tidalctl");

    assert!(!output.status.success(), "unknown flag should exit 1");
    let stderr = String::from_utf8(output.stderr).expect("utf8");
    let json: serde_json::Value =
        serde_json::from_str(stderr.trim()).expect("error envelope must be valid JSON");
    let msg = json["error"].as_str().expect("error is a string");
    assert!(
        msg.contains("--bad\nflag"),
        "error message must preserve the embedded control char: {stderr:?}"
    );
}

// ── diagnostics: real signal-entry count + real WAL replay lag ───────────────

/// `signal_estimated_entries` must report an ACTUAL event count, not the old
/// `last_segment_seq` proxy. `home_with_wal_data` appends exactly 5 events at
/// sequences 1..=5, so the count must be 5 — the sequence-number proxy would
/// have reported the segment's first sequence (1) instead.
#[test]
fn diagnostics_signal_entries_is_real_count_not_seq_proxy() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    assert_eq!(
        json["signal_estimated_entries"], 5,
        "should be the real 5-event count, not a sequence proxy: {stdout}"
    );
}

/// `wal_lag_bytes` must be a REAL lag metric, not a verbatim copy of
/// `wal_total_bytes`. With a checkpoint advanced PAST every event, nothing is
/// left to replay, so lag must be 0 while total bytes stay > 0 — a duplicate
/// field could never produce that divergence.
#[test]
fn diagnostics_wal_lag_is_zero_when_fully_checkpointed() {
    let home = TempTidalHome::new().expect("create temp home");
    home.paths().ensure_all().expect("create subdirs");

    let config = WalConfig {
        dir: home.path().to_path_buf(),
        ..WalConfig::default()
    };
    let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
    let mut last_seq = 0;
    for i in 1..=5 {
        last_seq = handle
            .append(SignalEvent {
                entity_id: i,
                signal_type: 1,
                weight: 1.0,
                timestamp_nanos: i * 1_000_000_000,
            })
            .expect("append event");
    }
    // Checkpoint past the last written sequence: every event is now durable, so
    // there is zero replay backlog.
    handle
        .checkpoint(last_seq + 1)
        .expect("checkpoint past tail");
    handle.shutdown().expect("shutdown WAL");

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    let total = json["wal_total_bytes"].as_u64().expect("total is a number");
    let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
    assert!(total > 0, "should have WAL bytes on disk: {stdout}");
    assert_eq!(
        lag, 0,
        "fully-checkpointed WAL has zero replay lag (not a copy of total={total}): {stdout}"
    );
}

/// With a checkpoint covering only part of the WAL, lag must be non-zero and at
/// most the total — a real subset, again proving it is not a duplicate of total.
#[test]
fn diagnostics_wal_lag_is_nonzero_with_uncheckpointed_tail() {
    // home_with_wal_data checkpoints at seq 3 but writes events 1..=5, so events
    // 3,4,5 are uncheckpointed -> non-zero lag.
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl diagnostics");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    let total = json["wal_total_bytes"].as_u64().expect("total is a number");
    let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
    assert!(lag > 0, "uncheckpointed tail must report lag: {stdout}");
    assert!(lag <= total, "lag is a subset of total bytes: {stdout}");
}

// ── diagnostics: offline-unavailable reasons single-sourced ──────────────────

/// The JSON `offline_unavailable` reasons and the pretty-output reasons must be
/// the SAME strings (single-sourced from the REASON_* constants). We assert the
/// pretty output contains each exact JSON reason verbatim.
#[test]
fn diagnostics_offline_reasons_single_sourced_across_json_and_pretty() {
    let home = home_with_wal_data();

    let json_out = tidalctl_bin()
        .args(["diagnostics", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run diagnostics json");
    assert!(json_out.status.success());
    let json: serde_json::Value =
        serde_json::from_str(&String::from_utf8(json_out.stdout).expect("utf8")).expect("json");

    let pretty_out = tidalctl_bin()
        .args([
            "diagnostics",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run diagnostics pretty");
    assert!(pretty_out.status.success());
    let pretty = String::from_utf8(pretty_out.stdout).expect("utf8");

    let reasons = json["offline_unavailable"]
        .as_object()
        .expect("offline_unavailable is an object");
    assert!(!reasons.is_empty(), "expected at least one reason");
    for (field, reason) in reasons {
        let reason = reason.as_str().expect("reason is a string");
        assert!(
            pretty.contains(reason),
            "pretty output must reuse the JSON reason for {field}: {reason:?}\n{pretty}"
        );
    }
}

// ── recover: session_journal summary must be in the output ───────────────────

/// `recover --verify-only` JSON must include the `session_journal` summary that
/// `diagnose_wal` now produces — the old hand-rolled builder dropped it.
#[test]
fn recover_json_includes_session_journal_summary() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            home.path().to_str().unwrap(),
        ])
        .output()
        .expect("run tidalctl");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    let sj = json["session_journal"]
        .as_object()
        .unwrap_or_else(|| panic!("session_journal must be present: {stdout}"));
    assert!(sj.contains_key("present"), "present field: {stdout}");
    assert!(sj.contains_key("file_size"), "file_size field: {stdout}");
    assert!(
        sj.contains_key("event_count"),
        "event_count field: {stdout}"
    );
    assert!(
        sj.contains_key("corrupt_records"),
        "corrupt_records field: {stdout}"
    );
    assert!(
        json["session_journal"]["present"].is_boolean(),
        "present is a bool: {stdout}"
    );
}

/// The pretty recover output must also render the session-journal section.
#[test]
fn recover_pretty_includes_session_journal_section() {
    let home = home_with_wal_data();

    let output = tidalctl_bin()
        .args([
            "recover",
            "--verify-only",
            "--path",
            home.path().to_str().unwrap(),
            "--pretty",
        ])
        .output()
        .expect("run tidalctl");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    assert!(
        stdout.contains("Session Journal:"),
        "pretty recover must include session-journal section: {stdout}"
    );
}

// ── scope-stats: torn-tail / inconsistency indicator ─────────────────────────

/// Append raw garbage after the last valid WAL batch to simulate a torn/corrupt
/// tail. `read_all_events` stops at the bad magic and silently undercounts, so
/// scope-stats must surface `complete: false` with a non-zero
/// `inconsistency_count` instead of pretending the tally is whole.
#[test]
fn scope_stats_torn_tail_reports_inconsistency() {
    use std::io::Write;

    let home = home_with_scoped_wal_data();
    let wal_dir = home.paths().wal_dir();

    // Find the (single) WAL segment file and append garbage past the last batch.
    let seg_path = std::fs::read_dir(&wal_dir)
        .expect("read wal dir")
        .filter_map(Result::ok)
        .map(|e| e.path())
        .find(|p| {
            let is_seg = p
                .extension()
                .is_some_and(|ext| ext.eq_ignore_ascii_case("seg"));
            let has_prefix = p
                .file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with("wal-"));
            has_prefix && is_seg
        })
        .expect("a WAL segment file must exist");

    let mut f = std::fs::OpenOptions::new()
        .append(true)
        .open(&seg_path)
        .expect("open segment for append");
    // Bytes that are NOT the batch magic -> the scan stops here and counts a
    // corrupt/truncated tail.
    f.write_all(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03])
        .expect("append garbage tail");
    f.sync_all().expect("sync");
    drop(f);

    let output = tidalctl_bin()
        .args(["scope-stats", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl scope-stats");
    assert!(
        output.status.success(),
        "scope-stats must still succeed on a torn tail: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");

    let body = &json["scope_stats"];
    assert_eq!(
        body["complete"], false,
        "torn tail must mark the tally incomplete: {stdout}"
    );
    assert!(
        body["inconsistency_count"].as_u64().unwrap_or(0) > 0,
        "torn tail must surface a non-zero inconsistency_count: {stdout}"
    );
    // The valid prefix is still tallied (3 local + 2 community + 1 session were
    // written before the garbage).
    assert!(
        body["total_events"].as_u64().unwrap_or(0) >= 1,
        "the valid prefix must still be counted: {stdout}"
    );
}

/// A clean WAL must report `complete: true` and zero inconsistencies, so the
/// indicator is meaningful (not always-false).
#[test]
fn scope_stats_clean_wal_reports_complete() {
    let home = home_with_scoped_wal_data();
    let output = tidalctl_bin()
        .args(["scope-stats", "--path", home.path().to_str().unwrap()])
        .output()
        .expect("run tidalctl scope-stats");
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).expect("utf8");
    let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
    let body = &json["scope_stats"];
    assert_eq!(body["complete"], true, "clean WAL is complete: {stdout}");
    assert_eq!(
        body["inconsistency_count"], 0,
        "clean WAL has no inconsistencies: {stdout}"
    );
}
