//! Unit and integration tests for the core CRDT state module.
//!
//! Covers op replay, index rebuild, round-trip persistence, the
//! Lamport-floor invariant, and the persist-channel error path.

use super::super::hex;
use super::super::read::extract_item_view;
use super::super::types::PipelineDoc;
use super::init::PersistMsg;
use super::*;
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
use bft_json_crdt::keypair::make_keypair;
use bft_json_crdt::op::ROOT_ID;
use serde_json::json;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
use std::collections::HashMap;
use tokio::sync::mpsc;

#[test]
fn crdt_ops_replay_reconstructs_state() {
    let kp = make_keypair();
    let mut crdt1 = BaseCrdt::<PipelineDoc>::new(&kp);

    // Build state with a series of ops.
    let item_json: JsonValue = json!({
        "story_id": "30_story_replay",
        "stage": "1_backlog",
        "name": "Replay Test",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
    })
    .into();

    let op1 = crdt1.doc.items.insert(ROOT_ID, item_json).sign(&kp);
    crdt1.apply(op1.clone());

    let op2 = crdt1.doc.items[0]
        .stage
        .set("2_current".to_string())
        .sign(&kp);
    crdt1.apply(op2.clone());

    let op3 = crdt1.doc.items[0]
        .name
        .set("Updated Name".to_string())
        .sign(&kp);
    crdt1.apply(op3.clone());

    // Replay ops on a fresh CRDT.
    let mut crdt2 = BaseCrdt::<PipelineDoc>::new(&kp);
    crdt2.apply(op1);
    crdt2.apply(op2);
    crdt2.apply(op3);

    assert_eq!(
        crdt1.doc.items[0].stage.view(),
        crdt2.doc.items[0].stage.view()
    );
    assert_eq!(
        crdt1.doc.items[0].name.view(),
        crdt2.doc.items[0].name.view()
    );
}

#[test]
fn rebuild_index_maps_story_ids() {
    let kp = make_keypair();
    let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

    for (sid, stage) in &[("10_story_a", "1_backlog"), ("20_story_b", "2_current")] {
        let item: JsonValue = json!({
            "story_id": sid,
            "stage": stage,
            "name": "",
            "agent": "",
            "retry_count": 0.0,
            "blocked": false,
            "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
        })
        .into();
        let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
        crdt.apply(op);
    }

    let index = rebuild_index(&crdt);
    assert_eq!(index.len(), 2);
    assert!(index.contains_key("10_story_a"));
    assert!(index.contains_key("20_story_b"));
}

/// Regression for story 906: `subscribe()` and `emit_event()` must share the
/// same broadcast channel.  Previously they referenced two separate
/// `CRDT_EVENT_TX` statics (one in `types.rs`, one in `state/statics.rs`),
/// so events fired into one channel and subscribers listened on the other —
/// no Matrix stage-transition notifications fired post-CRDT migration.
#[tokio::test(flavor = "current_thread")]
async fn subscribe_receives_stage_transition_events() {
    use super::super::types::CrdtEvent;
    use super::super::write::write_item_str;

    crate::crdt_state::init_for_test();

    let mut rx = super::subscribe().expect("subscribe must return Some after init_for_test");

    // Insert a new item — emit_event fires with from_stage=None.
    write_item_str(
        "906_story_subscribe",
        "1_backlog",
        Some("Subscribe Wiring"),
        None,
        None,
        None,
    );

    // Drain any stale events from concurrent tests until we see ours.
    // CRDT_EVENT_TX is global; parallel tests emit to the same channel.
    let evt: CrdtEvent = loop {
        let e = rx
            .try_recv()
            .expect("expected CrdtEvent for 906_story_subscribe");
        if e.story_id == "906_story_subscribe" {
            break e;
        }
    };
    assert!(evt.from_stage.is_none());
    assert!(matches!(
        evt.to_stage,
        crate::pipeline_state::Stage::Backlog
    ));

    // Update stage — emit_event fires again with the real from_stage.
    write_item_str("906_story_subscribe", "2_current", None, None, None, None);

    let evt: CrdtEvent = loop {
        let e = rx
            .try_recv()
            .expect("expected CrdtEvent for 906_story_subscribe stage change");
        if e.story_id == "906_story_subscribe" {
            break e;
        }
    };
    assert!(matches!(
        evt.from_stage,
        Some(crate::pipeline_state::Stage::Backlog)
    ));
    assert!(matches!(
        evt.to_stage,
        crate::pipeline_state::Stage::Coding { .. }
    ));
}

#[tokio::test]
async fn init_and_write_read_roundtrip() {
    let tmp = tempfile::tempdir().unwrap();
    let db_path = tmp.path().join("crdt_test.db");

    // Init directly (not via the global singleton, for test isolation).
    let options = SqliteConnectOptions::new()
        .filename(&db_path)
        .create_if_missing(true);
    let pool = SqlitePool::connect_with(options).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();

    let keypair = make_keypair();
    let mut crdt = BaseCrdt::<PipelineDoc>::new(&keypair);

    // Insert and update like write_item does.
    let item_json: JsonValue = json!({
        "story_id": "50_story_roundtrip",
        "stage": "backlog",
        "name": "Roundtrip",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
    })
    .into();

    let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&keypair);
    crdt.apply(insert_op.clone());

    // Persist the op.
    let op_json = serde_json::to_string(&insert_op).unwrap();
    let op_id = hex::encode(&insert_op.id());
    let now = chrono::Utc::now().to_rfc3339();
    sqlx::query("INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)")
        .bind(&op_id)
        .bind(insert_op.inner.seq as i64)
        .bind(&op_json)
        .bind(&now)
        .execute(&pool)
        .await
        .unwrap();

    // Reconstruct from DB.
    let rows: Vec<(String,)> = sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC")
        .fetch_all(&pool)
        .await
        .unwrap();

    let mut crdt2 = BaseCrdt::<PipelineDoc>::new(&keypair);
    for (json_str,) in &rows {
        let op: SignedOp = serde_json::from_str(json_str).unwrap();
        crdt2.apply(op);
    }

    let view = extract_item_view(&crdt2.doc.items[0]).unwrap();
    assert_eq!(view.story_id, "50_story_roundtrip");
    assert!(matches!(view.stage, crate::pipeline_state::Stage::Backlog));
    assert_eq!(view.name, "Roundtrip");
}

#[test]
fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
    let kp = make_keypair();
    let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
    let (persist_tx, persist_rx) = mpsc::unbounded_channel::<PersistMsg>();

    let mut state = CrdtState {
        crdt,
        keypair: kp,
        index: HashMap::new(),
        node_index: HashMap::new(),
        token_index: HashMap::new(),
        merge_job_index: HashMap::new(),
        active_agent_index: HashMap::new(),
        test_job_index: HashMap::new(),
        agent_throttle_index: HashMap::new(),
        gateway_project_index: HashMap::new(),
        llm_session_index: HashMap::new(),
        persist_tx,
        lamport_floor: 0,
        tombstones: std::collections::HashSet::new(),
    };

    // Drop the receiver so that the next send fails immediately.
    drop(persist_rx);

    let item_json: JsonValue = json!({
        "story_id": "676_story_persist_fail",
        "stage": "1_backlog",
        "name": "Persist Fail Test",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
    })
    .into();

    let before_warns = crate::log_buffer::global()
        .get_recent_entries(1000, None, Some(&crate::log_buffer::LogLevel::Warn))
        .len();

    apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json));

    let warn_entries = crate::log_buffer::global().get_recent_entries(
        1000,
        None,
        Some(&crate::log_buffer::LogLevel::Warn),
    );

    assert_eq!(
        warn_entries.len(),
        before_warns + 1,
        "expected exactly one WARN log entry when persist_tx send fails"
    );

    let warn = &warn_entries[warn_entries.len() - 1];
    assert!(
        warn.message.contains("[crdt_persist]"),
        "WARN message must be prefixed [crdt_persist]: {}",
        warn.message
    );
    assert!(
        warn.message.contains("op_type="),
        "WARN message must include op_type: {}",
        warn.message
    );
    assert!(
        warn.message.contains("seq="),
        "WARN message must include seq: {}",
        warn.message
    );
}

#[test]
fn persist_tx_send_success_emits_no_warn() {
    let kp = make_keypair();
    let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
    let (persist_tx, _persist_rx) = mpsc::unbounded_channel::<PersistMsg>();

    let mut state = CrdtState {
        crdt,
        keypair: kp,
        index: HashMap::new(),
        node_index: HashMap::new(),
        token_index: HashMap::new(),
        merge_job_index: HashMap::new(),
        active_agent_index: HashMap::new(),
        test_job_index: HashMap::new(),
        agent_throttle_index: HashMap::new(),
        gateway_project_index: HashMap::new(),
        llm_session_index: HashMap::new(),
        persist_tx,
        lamport_floor: 0,
        tombstones: std::collections::HashSet::new(),
    };

    let item_json: JsonValue = json!({
        "story_id": "676_story_happy_path",
        "stage": "1_backlog",
        "name": "Happy Path Test",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
    })
    .into();

    // Count only warns mentioning THIS test's story: the log buffer is
    // process-global, so parallel tests emit their own [crdt_persist] warns
    // and a bare count races (flaked when the logging-list feature removal
    // changed test timing).
    let count_own_warns = || {
        crate::log_buffer::global()
            .get_recent_entries(
                1000,
                Some("[crdt_persist]"),
                Some(&crate::log_buffer::LogLevel::Warn),
            )
            .iter()
            .filter(|e| e.message.contains("676_story_happy_path"))
            .count()
    };

    let before_warns = count_own_warns();

    apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json));

    let after_warns = count_own_warns();

    assert_eq!(
        after_warns, before_warns,
        "no [crdt_persist] WARN should be emitted when persist_tx send succeeds"
    );
}

/// After replaying ops from a journal, a brand-new register created
/// post-init must emit its first local op with seq = lamport_floor + 1,
/// not seq = 1.  This is the Phase C integration test.
#[tokio::test]
async fn restart_new_register_resumes_from_lamport_floor() {
    let tmp = tempfile::tempdir().unwrap();
    let db_path = tmp.path().join("lamport_floor.db");

    let options = SqliteConnectOptions::new()
        .filename(&db_path)
        .create_if_missing(true);
    let pool = SqlitePool::connect_with(options).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();

    let kp = make_keypair();
    let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

    // Insert an item and update its stage a few times to push seq up.
    let item: JsonValue = json!({
        "story_id": "664_story_original",
        "stage": "1_backlog",
        "name": "Original",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
        "merged_at": 0.0,
    })
    .into();

    let mut ops = Vec::new();

    let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
    crdt.apply(op1.clone());
    ops.push(op1);

    let idx = rebuild_index(&crdt)["664_story_original"];
    let op2 = crdt.doc.items[idx]
        .stage
        .set("2_current".to_string())
        .sign(&kp);
    crdt.apply(op2.clone());
    ops.push(op2);

    let op3 = crdt.doc.items[idx]
        .stage
        .set("3_review".to_string())
        .sign(&kp);
    crdt.apply(op3.clone());
    ops.push(op3);

    // Record the max seq across all persisted ops — this is the floor.
    let max_seq = ops.iter().map(|o| o.inner.seq).max().unwrap();

    // Persist all ops.
    let now = chrono::Utc::now().to_rfc3339();
    for op in &ops {
        let op_json = serde_json::to_string(op).unwrap();
        let op_id = hex::encode(&op.id());
        sqlx::query(
            "INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)",
        )
        .bind(&op_id)
        .bind(op.inner.seq as i64)
        .bind(&op_json)
        .bind(&now)
        .execute(&pool)
        .await
        .unwrap();
    }

    // --- Simulate restart: replay from journal into a fresh CRDT ---
    let rows: Vec<(String,)> = sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC")
        .fetch_all(&pool)
        .await
        .unwrap();

    let mut crdt2 = BaseCrdt::<PipelineDoc>::new(&kp);
    let mut lamport_floor: u64 = 0;
    for (json_str,) in &rows {
        let signed: SignedOp = serde_json::from_str(json_str).unwrap();
        lamport_floor = lamport_floor.max(signed.inner.seq);
        crdt2.apply(signed);
    }

    // Advance top-level lists (mirrors what init() does).
    crdt2.doc.items.advance_seq(lamport_floor);
    crdt2.doc.nodes.advance_seq(lamport_floor);

    assert_eq!(lamport_floor, max_seq);

    // Insert a brand-new item — simulating a new story arriving after restart.
    let new_item: JsonValue = json!({
        "story_id": "664_story_new_after_restart",
        "stage": "1_backlog",
        "name": "New After Restart",
        "agent": "",
        "retry_count": 0.0,
        "blocked": false,
        "depends_on": "",
        "claimed_by": "",
        "claimed_at": 0.0,
        "merged_at": 0.0,
    })
    .into();

    let insert_op = crdt2.doc.items.insert(ROOT_ID, new_item);
    // The list-level insert must have seq > lamport_floor.
    assert!(
        insert_op.seq > max_seq,
        "list insert seq ({}) must be > lamport_floor ({})",
        insert_op.seq,
        max_seq,
    );
    let insert_signed = insert_op.sign(&kp);
    crdt2.apply(insert_signed);

    // Advance the new item's inner registers to the floor (mirrors write_item).
    let idx2 = rebuild_index(&crdt2)["664_story_new_after_restart"];
    let new_crdt_item = &mut crdt2.doc.items[idx2];
    new_crdt_item.stage.advance_seq(lamport_floor);

    // Now update the stage — the first field-level op must also be > floor.
    let stage_op = crdt2.doc.items[idx2].stage.set("2_current".to_string());
    assert!(
        stage_op.seq > max_seq,
        "first field op seq ({}) on new register must be > lamport_floor ({}); \
         got seq = 1 means the register reset its clock on restart",
        stage_op.seq,
        max_seq,
    );
}

/// Regression test for story 1116: ops sent before `flush_persistence` must all be
/// present in the `crdt_ops` SQLite table after the flush completes.
///
/// Bug: `rebuild_and_restart` called `exec()` before the persistence task had
/// a chance to drain the unbounded channel, silently dropping queued ops.
///
/// Reproducer: apply N ops → call `rebuild_and_restart` → the process re-execs
/// and on the next startup `persisted_ops_count` is < N (lost ops).
/// Fixed by: send a `Flush` sentinel through the channel before `exec()`; the
/// task echoes back only after all preceding `Op` messages are committed.
#[tokio::test]
async fn flush_persistence_drains_all_ops_before_ack() {
    use std::sync::atomic::Ordering;
    use tokio::sync::oneshot;

    let tmp = tempfile::tempdir().unwrap();
    let db_path = tmp.path().join("flush_drain_test.db");

    let options = SqliteConnectOptions::new()
        .filename(&db_path)
        .create_if_missing(true);
    let pool = SqlitePool::connect_with(options).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();

    let kp = make_keypair();
    let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

    // Spawn an isolated persistence task — same logic as init() but without
    // touching the global singleton (keeping this test fully self-contained).
    let (tx, mut rx) = mpsc::unbounded_channel::<PersistMsg>();
    let pool_clone = pool.clone();
    tokio::spawn(async move {
        use std::sync::atomic::AtomicUsize;
        let counter = AtomicUsize::new(0);
        while let Some(msg) = rx.recv().await {
            match msg {
                PersistMsg::Op(op) => {
                    let op_json = serde_json::to_string(&op).unwrap();
                    let op_id = hex::encode(&op.id());
                    let seq = op.inner.seq as i64;
                    let now = chrono::Utc::now().to_rfc3339();
                    sqlx::query(
                        "INSERT INTO crdt_ops (op_id, seq, op_json, created_at) \
                         VALUES (?1, ?2, ?3, ?4) ON CONFLICT(op_id) DO NOTHING",
                    )
                    .bind(&op_id)
                    .bind(seq)
                    .bind(&op_json)
                    .bind(&now)
                    .execute(&pool_clone)
                    .await
                    .unwrap();
                    counter.fetch_add(1, Ordering::Relaxed);
                }
                PersistMsg::Flush(reply) => {
                    let _ = reply.send(());
                }
            }
        }
    });

    const N: usize = 10;
    for i in 0..N {
        let item: JsonValue = json!({
            "story_id": format!("1116_drain_{i}"),
            "stage": "1_backlog",
            "name": format!("Drain Test {i}"),
            "agent": "",
            "retry_count": 0.0,
            "blocked": false,
            "depends_on": "",
            "claimed_by": "",
            "claimed_at": 0.0,
        })
        .into();
        let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
        crdt.apply(op.clone());
        tx.send(PersistMsg::Op(Box::new(op))).unwrap();
    }

    // Send flush sentinel and wait — all N ops must be committed first.
    let (flush_tx, flush_rx) = oneshot::channel();
    tx.send(PersistMsg::Flush(flush_tx)).unwrap();
    tokio::time::timeout(std::time::Duration::from_secs(5), flush_rx)
        .await
        .expect("flush timed out — persistence task did not drain within 5 s")
        .expect("flush oneshot dropped unexpectedly");

    // Verify all N ops are in the database.
    let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM crdt_ops")
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(
        count as usize, N,
        "all {N} ops must be in crdt_ops after flush; got {count}"
    );
}
