//! Crash safety (AGENTS.md "Testing"): kill the process mid-transaction with
//! SIGKILL, reopen, and prove the file is not just readable but *consistent*
//! — the derived graph tables must equal a full replay of the oplog, because
//! both are written in the same transaction. WAL should make this boring;
//! this harness proves it stays boring.
//!
//! Mechanism: the parent test re-spawns this same test binary filtered to the
//! `#[ignore]`d `crash_writer_child` test, which writes in a tight loop until
//! it is SIGKILLed at a random point.

use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;

use grit_core::{GraphOp, Grit, ManualClock, Options};
use serde_json::json;
use uuid::Uuid;

const DB_ENV: &str = "GRIT_CRASH_DB";

/// Not a real test: the writer child. Only does anything when spawned by
/// `crash_safety_survives_kill9` with the db path in the environment.
#[test]
#[ignore = "writer child for the crash harness; spawned by crash_safety_survives_kill9"]
fn crash_writer_child() {
    let Some(path) = std::env::var_os(DB_ENV) else {
        return;
    };
    let g = Grit::open(&path, Options::new("crash-child")).unwrap();
    let mut nodes: Vec<Uuid> = Vec::new();
    let mut edges: Vec<Uuid> = Vec::new();
    for i in 0u64.. {
        let node = g.new_id();
        g.apply(GraphOp::AddNode {
            id: node,
            kind: "k".into(),
            name: format!("node-{i}"),
            summary: format!("summary {i}"),
            attrs: json!({ "i": i }),
            group_id: format!("g{}", i % 4),
        })
        .unwrap();
        nodes.push(node);
        if nodes.len() >= 2 {
            let edge = g.new_id();
            g.apply(GraphOp::AddEdge {
                id: edge,
                src: nodes[(i as usize * 7) % nodes.len()],
                dst: nodes[(i as usize * 13) % nodes.len()],
                rel: "R".into(),
                fact: format!("fact {i}"),
                attrs: json!({}),
                group_id: format!("g{}", i % 4),
                valid_at: Some(1_000 + i as i64),
            })
            .unwrap();
            edges.push(edge);
        }
        if i % 5 == 0 && !edges.is_empty() {
            g.apply(GraphOp::InvalidateEdge {
                edge_id: edges[(i as usize * 3) % edges.len()],
                invalid_at: 2_000 + i as i64,
            })
            .unwrap();
        }
        if i % 7 == 0 {
            g.apply(GraphOp::AddEpisode {
                id: g.new_id(),
                source: "crash".into(),
                content: format!("episode {i}"),
                occurred_at: 1_500 + i as i64,
                group_id: format!("g{}", i % 4),
                mentions: vec![node],
            })
            .unwrap();
        }
        // Runs until SIGKILL.
    }
}

/// Everything except the oplog itself (local seq/applied_at differ by design).
fn graph_dump(g: &Grit) -> String {
    let mut buf = Vec::new();
    g.export_jsonl(&mut buf).unwrap();
    let mut lines: Vec<&str> = std::str::from_utf8(&buf)
        .unwrap()
        .lines()
        .filter(|l| !l.contains("\"grit_export\"") && !l.contains("\"t\":\"oplog\""))
        .collect();
    lines.sort_unstable();
    lines.join("\n")
}

#[test]
fn crash_safety_survives_kill9() {
    let dir = tempfile::tempdir().unwrap();
    let db = dir.path().join("crash.db");

    let mut total_ops = 0usize;
    for round in 0..5u64 {
        // Spawn this same test binary as the writer child.
        let mut child = Command::new(std::env::current_exe().unwrap())
            .args(["crash_writer_child", "--exact", "--ignored", "--nocapture"])
            .env(DB_ENV, &db)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap();
        // Let it write for a while, then SIGKILL mid-flight. Varied delays
        // land the kill at different points in the commit cycle.
        std::thread::sleep(Duration::from_millis(150 + round * 90));
        child.kill().unwrap();
        child.wait().unwrap();

        // 1. The file itself is sound.
        let conn = rusqlite::Connection::open(&db).unwrap();
        let integrity: String = conn
            .query_row("PRAGMA integrity_check", [], |r| r.get(0))
            .unwrap();
        assert_eq!(integrity, "ok", "round {round}: integrity_check failed");
        drop(conn);

        // 2. Grit reopens it (WAL recovery) and the derived tables equal a
        //    full replay of the oplog — no half-applied op survived, in
        //    either direction.
        let g = Grit::open(&db, Options::new("crash-verify")).unwrap();
        let ops = g.oplog_since(0).unwrap();
        assert!(
            ops.len() >= total_ops,
            "round {round}: oplog went backwards ({} < {total_ops})",
            ops.len()
        );
        total_ops = ops.len();

        let replay = Grit::open(
            dir.path().join(format!("replay-{round}.db")),
            Options::new("replay").clock(Arc::new(ManualClock::new(1))),
        )
        .unwrap();
        for (_, entry) in ops {
            replay.apply_remote(entry).unwrap();
        }
        assert_eq!(
            graph_dump(&g),
            graph_dump(&replay),
            "round {round}: derived state diverged from oplog replay after kill -9"
        );
    }
    assert!(total_ops > 0, "harness never observed any committed ops");
}
