//! Milestone 7: Mid-write crash recovery for the M6 surfaces.
//!
//! The four crash points below are wired into the M6-surface write paths but
//! were never *armed* by any test — `m7_crash_m6.rs` only exercises clean
//! `close()` + reopen roundtrips, and `m7_crash_property.rs` only arms the WAL /
//! signal-ledger crash points. This file closes that gap: for each M6 crash
//! point we open a DB, perform the corresponding write, ARM the injector so the
//! process panics mid-write, let `Drop` run the durability path during unwind,
//! reopen, and assert the recovered surface satisfies the WAL crash tests'
//! durability bounds — **no fully-acked write lost, no write duplicated**.
//!
//! Crash points under test (exact `CrashPoint` variants):
//! 1. `CrashPoint::CohortLedgerUpdate`    — `CohortSignalLedger::record`, after
//!    the hot-tier `on_signal` CAS, before the warm-tier `increment`
//!    (`src/cohort/ledger.rs:100`).
//! 2. `CrashPoint::CollectionIndexUpdate` — `TidalDb::add_to_collection`, after
//!    `persist_collection`, before return (`src/db/collections.rs:78`).
//! 3. `CrashPoint::CoEngagementUpdate`    — `CoEngagementIndex::record_positive`,
//!    after the edge-insertion loop, before the eviction check
//!    (`src/entities/co_engagement.rs:98`).
//! 4. `CrashPoint::CheckpointPostFlush`   — `signals::checkpoint`, *after*
//!    `write_batch` + `flush` (`src/signals/checkpoint/mod.rs:118`).
//!
//! ## Durability model
//!
//! Cohort + co-engagement state is **checkpoint-only** (no WAL): `record` /
//! `record_positive` mutate in-memory state and are persisted by the final
//! checkpoint that `shutdown_inner` runs from both `close()` AND `Drop`. When an
//! armed injector panics mid-write, the unwind drops the `TidalDb`, `Drop` runs
//! `shutdown_inner`, and the *current* in-memory state is checkpointed. The
//! injector is one-shot, so the crash aborts the in-flight call (and the
//! enclosing write loop) but the durability path still runs to completion.
//!
//! Because the crash points sit at precise pre/post boundaries we can assert
//! *exact* recovered counts rather than just bounds:
//! - `CohortLedgerUpdate` fires before `warm.increment`, so a crash on call
//!   `K+1` (after `K` complete) leaves the warm counter at exactly `K`.
//! - `CollectionIndexUpdate` fires after `persist_collection`, so a crash on the
//!   add of item `K+1` (after `K` complete adds) leaves items `1..=K+1` durable.
//! - `CoEngagementUpdate` fires after the edge-insertion loop, so edges from the
//!   crashing call are already in-memory and checkpointed by `Drop`.
//! - `CheckpointPostFlush` fires after the flush completes, so the checkpoint is
//!   fully durable and every surface must recover exactly.

#![allow(
    clippy::too_many_lines,
    clippy::unwrap_used,
    clippy::cast_precision_loss
)]

use std::{collections::HashMap, time::Duration};

use tidaldb::{
    TidalDb,
    cohort::{CohortDef, Predicate},
    entities::Visibility,
    schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
    testing::{CrashInjector, CrashPoint, crash_injector::run_with_crash},
};

// ── Schema + metadata helpers ────────────────────────────────────────────────

/// M6 schema with view + like signals across all windows and velocity enabled.
fn m6_schema() -> tidaldb::schema::Schema {
    let mut builder = SchemaBuilder::new();
    for &(name, half_life_days) in &[("view", 7u64), ("like", 14)] {
        let _ = builder
            .signal(
                name,
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(half_life_days * 24 * 3600),
                },
            )
            .windows(&[
                Window::OneHour,
                Window::TwentyFourHours,
                Window::SevenDays,
                Window::AllTime,
            ])
            .velocity(true)
            .add();
    }
    builder.build().expect("m6 schema must be valid")
}

fn item_metadata(category: &str, creator_id: u64) -> HashMap<String, String> {
    let mut meta = HashMap::new();
    meta.insert("category".to_string(), category.to_string());
    meta.insert("creator_id".to_string(), creator_id.to_string());
    meta
}

fn user_metadata(locale: &str, primary_category: &str) -> HashMap<String, String> {
    let mut m = HashMap::new();
    m.insert("locale".to_string(), locale.to_string());
    m.insert("primary_category".to_string(), primary_category.to_string());
    m
}

fn open_db(dir: &std::path::Path, schema: &tidaldb::schema::Schema) -> TidalDb {
    TidalDb::builder()
        .with_data_dir(dir)
        .with_schema(schema.clone())
        .open()
        .unwrap()
}

// ── Test 1: CohortLedgerUpdate mid-write crash ───────────────────────────────
//
// Arms `CrashPoint::CohortLedgerUpdate` (src/cohort/ledger.rs:100). We record
// N cohort-attributed `like` signals onto a SINGLE item for a single cohort, so
// the per-key warm counter accumulates. The injector fires on cohort `record`
// call K+1 — after `hot.on_signal`, before `warm.increment` — so that call's
// warm increment is lost and the loop aborts. `Drop` checkpoints the in-memory
// state (warm == K). On reopen, the recovered cohort windowed count must be
// EXACTLY K: every fully-acked record present (no loss), the in-flight one
// dropped and never re-applied (no duplicate — cohort state is checkpoint-only,
// so there is no WAL-replay double-apply).

#[test]
fn cohort_ledger_update_crash_recovers_exactly_acked() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6_schema();

    let total_likes = 8u64;
    let crash_after = 5u64; // K = 5 complete records, crash on the 6th.
    let user = 1001u64;
    let item = EntityId::new(1);
    let cohort = "tech_en";

    {
        let db = open_db(dir.path(), &schema);

        db.define_cohort(CohortDef {
            name: cohort.to_string(),
            predicate: Predicate::And(vec![
                Predicate::Eq {
                    field: "locale".into(),
                    value: "en".into(),
                },
                Predicate::Eq {
                    field: "primary_category".into(),
                    value: "tech".into(),
                },
            ]),
        })
        .unwrap();

        db.write_user(EntityId::new(user), &user_metadata("en", "tech"))
            .unwrap();
        db.write_item_with_metadata(item, &item_metadata("tech", 100))
            .unwrap();

        // Each `like` with user context drives try_cohort_attribution -> record.
        // `like` is a positive-engagement signal, so it ALSO drives co-engagement,
        // but only cohort `record` crosses CohortLedgerUpdate.
        let injector = CrashInjector::new(CrashPoint::CohortLedgerUpdate, crash_after);
        let ts = Timestamp::now();
        let outcome = run_with_crash(&injector, || {
            for _ in 0..total_likes {
                db.signal_with_context("like", item, 1.0, ts, Some(user), Some(100))
                    .unwrap();
            }
            // db dropped here on the normal path; on crash, dropped during unwind.
            drop(db);
        });

        assert_eq!(
            outcome,
            Err(CrashPoint::CohortLedgerUpdate),
            "injector must fire at CohortLedgerUpdate"
        );
        assert!(injector.has_fired(), "injector should have fired");
    }

    // Reopen: cohort warm count must be EXACTLY the number of fully-acked records.
    {
        let db = open_db(dir.path(), &schema);
        let ledger = db.cohort_ledger();

        let count = ledger
            .read_windowed_count(cohort, item, "like", Window::AllTime)
            .unwrap();
        assert_eq!(
            count, crash_after,
            "cohort recovered count must equal acked records ({crash_after}): \
             no acked write lost, no in-flight/duplicate write applied; got {count}"
        );

        // Decay score must be finite + non-negative (recovered hot tier).
        if let Some(score) = ledger.read_decay_score(cohort, item, "like", 0).unwrap() {
            assert!(
                score.is_finite(),
                "recovered cohort decay score not finite: {score}"
            );
            assert!(
                score >= 0.0,
                "recovered cohort decay score negative: {score}"
            );
        }

        db.close().unwrap();
    }
}

// ── Test 2: CollectionIndexUpdate mid-write crash ────────────────────────────
//
// Arms `CrashPoint::CollectionIndexUpdate` (src/db/collections.rs:78). The crash
// point fires AFTER `persist_collection`, so on the add of item K+1 the in-memory
// index already holds item K+1 and the updated collection has already been
// persisted to durable storage. We crash on add K+1, the loop aborts, `Drop`
// re-persists the in-memory index. On reopen: items 1..=K+1 must be present
// (every acked add + the persisted-then-crashed add), items K+2.. must be absent
// (their adds never ran). No membership lost, no phantom membership.

#[test]
fn collection_index_update_crash_recovers_persisted_adds() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6_schema();
    let owner = EntityId::new(42);

    let total_items = 8u64; // items 10, 20, ..., 80
    let crash_after = 5u64; // K = 5 complete adds, crash on add #6 (item 60).

    let collection_name = "favorites";

    {
        let db = open_db(dir.path(), &schema);
        let cid = db
            .create_collection(owner, collection_name, Visibility::Private)
            .unwrap();

        let injector = CrashInjector::new(CrashPoint::CollectionIndexUpdate, crash_after);
        let outcome = run_with_crash(&injector, || {
            for i in 1..=total_items {
                db.add_to_collection(cid, EntityId::new(i * 10)).unwrap();
            }
            drop(db);
        });

        assert_eq!(
            outcome,
            Err(CrashPoint::CollectionIndexUpdate),
            "injector must fire at CollectionIndexUpdate"
        );
        assert!(injector.has_fired(), "injector should have fired");
    }

    // Reopen: items 1..=K+1 present (acked adds + the persisted-then-crashed add),
    // items K+2.. absent.
    {
        let db = open_db(dir.path(), &schema);
        let collections = db.list_collections(owner).unwrap();
        assert_eq!(
            collections.len(),
            1,
            "the collection must survive the mid-write crash"
        );
        let col = collections
            .iter()
            .find(|c| c.name == collection_name)
            .expect("favorites collection must survive");

        let index = db.collection_index();

        // The crash fires AFTER persist on add K+1, so item K+1 is durable too.
        let durable_through = crash_after + 1;
        for i in 1..=durable_through {
            let item = i * 10;
            assert!(
                index.contains(col.id, u32::try_from(item).unwrap()),
                "item {item} (acked or persisted-then-crashed add) must survive restart"
            );
        }
        for i in (durable_through + 1)..=total_items {
            let item = i * 10;
            assert!(
                !index.contains(col.id, u32::try_from(item).unwrap()),
                "item {item} (add never ran) must NOT be present — no phantom membership"
            );
        }

        assert_eq!(
            col.item_count as u64, durable_through,
            "collection item_count must equal durable adds ({durable_through}); got {}",
            col.item_count
        );

        db.close().unwrap();
    }
}

// ── Test 3: CoEngagementUpdate mid-write crash ───────────────────────────────
//
// Arms `CrashPoint::CoEngagementUpdate` (src/entities/co_engagement.rs:98). The
// crash point fires AFTER the edge-insertion loop, before the eviction check.
// We drive co-engagement by liking items in sequence for one user, which on each
// `like` adds edges (new_item -> each prior item). We crash on `record_positive`
// call K+1: that call's edges were already inserted before the crash point, so
// `Drop` checkpoints all edges through call K+1. On reopen every edge produced
// by calls 1..=K+1 must survive with its exact weight; later calls never ran so
// their edges must be absent. No edge lost, no duplicate-weight inflation.

#[test]
fn co_engagement_update_crash_recovers_inserted_edges() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6_schema();

    let user = 7u64;
    // Like items in sequence: 10, 20, 30, 40, 50. Each like adds edges to all
    // prior liked items. record_positive call #k corresponds to liking item k*10.
    let items = [10u64, 20, 30, 40, 50];
    // K = 3 complete record_positive calls (likes of 10, 20, 30), crash on the
    // 4th call (like of 40). The 4th call's edges (40->10, 40->20, 40->30) are
    // inserted BEFORE the crash point, so they survive too.
    let crash_after = 3u64;

    {
        let db = open_db(dir.path(), &schema);
        for &id in &items {
            db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100))
                .unwrap();
        }

        let injector = CrashInjector::new(CrashPoint::CoEngagementUpdate, crash_after);
        let ts = Timestamp::now();
        let outcome = run_with_crash(&injector, || {
            for &id in &items {
                db.signal_with_context("like", EntityId::new(id), 1.0, ts, Some(user), Some(100))
                    .unwrap();
            }
            drop(db);
        });

        assert_eq!(
            outcome,
            Err(CrashPoint::CoEngagementUpdate),
            "injector must fire at CoEngagementUpdate"
        );
        assert!(injector.has_fired(), "injector should have fired");
    }

    // Reopen: edges from calls 1..=K+1 must survive. The crash fires on call
    // K+1 (like of item items[K]) AFTER its edges are inserted, so edges out of
    // items[K] back to items[0..K] are durable; edges out of items[K+1..] are not.
    {
        let db = open_db(dir.path(), &schema);
        let co_eng = db.co_engagement();

        let crash_idx = usize::try_from(crash_after).unwrap(); // index of the crashing like
        // Every directed edge (items[j] -> items[i]) for i < j and j <= crash_idx
        // must have weight 1.0 (each pair co-engaged exactly once).
        for j in 1..=crash_idx {
            for i in 0..j {
                let score = co_eng.score(EntityId::new(items[j]), EntityId::new(items[i]));
                assert!(
                    (score - 1.0).abs() < f32::EPSILON,
                    "edge ({}->{}) from acked/crashing like must be 1.0; got {score}",
                    items[j],
                    items[i]
                );
                // Asymmetric: reverse edge must be absent (no phantom edge).
                let reverse = co_eng.score(EntityId::new(items[i]), EntityId::new(items[j]));
                assert!(
                    (reverse - 0.0).abs() < f32::EPSILON,
                    "reverse edge ({}->{}) must be 0.0 (asymmetric); got {reverse}",
                    items[i],
                    items[j]
                );
            }
        }

        // Likes after the crash never ran, so their edges must be absent.
        for j in (crash_idx + 1)..items.len() {
            for i in 0..j {
                let score = co_eng.score(EntityId::new(items[j]), EntityId::new(items[i]));
                assert!(
                    (score - 0.0).abs() < f32::EPSILON,
                    "edge ({}->{}) from a never-run like must be absent; got {score}",
                    items[j],
                    items[i]
                );
            }
        }

        db.close().unwrap();
    }
}

// ── Test 4: CheckpointPostFlush crash ────────────────────────────────────────
//
// Arms `CrashPoint::CheckpointPostFlush` (src/signals/checkpoint/mod.rs:118).
// This point fires AFTER `write_batch` + `flush`, so the signal checkpoint is
// already fully durable on disk when the crash occurs. We write a full M6
// workload (signals + a collection), then `close()` — which runs the final
// checkpoint and crashes right after the signal-ledger flush.
//
// ## Why the signal surface recovers with at-least-once (not exactly-once)
//
// `shutdown_inner` flushes the signal checkpoint FIRST, then (later, after the
// crash point) writes the WAL checkpoint *marker* that fences WAL replay. The
// crash fires between those two steps, so the marker for this close is never
// written. On reopen, `db/open.rs:172-187` restores the durable checkpoint
// (which captured every view: count == views_per_item) AND then replays the WAL
// events that post-date the *previous* marker — i.e. the same view events — via
// `apply_wal_event`, re-adding them on top. This is the documented checkpoint +
// WAL overlap contract (see `m7_crash_property.rs` Test 2): the signal ledger
// guarantees **no acked write is lost**, with at-most-double overlap when a
// checkpoint is interrupted before its WAL marker lands. It does NOT promise
// exactly-once across an interrupted checkpoint — asserting that would be the
// wrong invariant. We therefore bound the signal surface no-lost + at-most-2x
// and assert exact recovery only for the collection surface, which is persisted
// per-add independently of the signal checkpoint/WAL and so is unaffected.

#[test]
fn checkpoint_post_flush_crash_recovers_durable_state() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6_schema();
    let owner = EntityId::new(1);

    let item_count = 6u64;
    let views_per_item = 3u64;

    {
        let db = open_db(dir.path(), &schema);

        for id in 1..=item_count {
            db.write_item_with_metadata(EntityId::new(id), &item_metadata("tech", 100))
                .unwrap();
        }

        // Signal-ledger surface: deterministic per-item view counts.
        let ts = Timestamp::now();
        for id in 1..=item_count {
            for _ in 0..views_per_item {
                db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
            }
        }

        // Collection surface (persisted per-add, independent of signal checkpoint).
        let cid = db
            .create_collection(owner, "best-of", Visibility::Public)
            .unwrap();
        for id in [1u64, 3, 5] {
            db.add_to_collection(cid, EntityId::new(id)).unwrap();
        }

        // Crash AFTER the signal checkpoint's flush completes. close() consumes
        // the db and runs shutdown_inner -> ledger.checkpoint -> flush -> fire.
        // The flush already persisted the checkpoint, so reopen must recover it.
        let injector = CrashInjector::new(CrashPoint::CheckpointPostFlush, 0);
        let outcome = run_with_crash(&injector, move || {
            let _ = db.close();
        });

        assert_eq!(
            outcome,
            Err(CrashPoint::CheckpointPostFlush),
            "injector must fire at CheckpointPostFlush"
        );
        assert!(injector.has_fired(), "injector should have fired");
    }

    // Reopen: durable checkpoint yields no-lost signal counts (with bounded
    // checkpoint+WAL overlap); collection surface recovers exactly.
    {
        let db = open_db(dir.path(), &schema);

        for id in 1..=item_count {
            let count = db
                .read_windowed_count(EntityId::new(id), "view", Window::AllTime)
                .unwrap();
            // No-lost: the durable checkpoint captured every acked view.
            assert!(
                count >= views_per_item,
                "item {id}: post-flush crash lost acked views — expected >= {views_per_item}; got {count}"
            );
            // Bounded overlap: an interrupted checkpoint (marker not yet written)
            // replays the same WAL events once on top — at most 2x, never runaway.
            assert!(
                count <= 2 * views_per_item,
                "item {id}: checkpoint+WAL overlap exceeded the 2x bound — \
                 expected <= {}; got {count}",
                2 * views_per_item
            );
            let score = db.read_decay_score(EntityId::new(id), "view", 0).unwrap();
            assert!(score.is_some(), "item {id} decay score must survive");
            let score = score.unwrap();
            assert!(
                score.is_finite() && score > 0.0,
                "item {id} score must be finite + positive: {score}"
            );
        }

        let collections = db.list_collections(owner).unwrap();
        assert_eq!(
            collections.len(),
            1,
            "collection must survive post-flush crash"
        );
        let col = &collections[0];
        assert_eq!(col.name, "best-of");
        assert_eq!(col.item_count, 3, "collection must recover all 3 items");
        let index = db.collection_index();
        for id in [1u64, 3, 5] {
            assert!(
                index.contains(col.id, u32::try_from(id).unwrap()),
                "collection item {id} must survive"
            );
        }
        assert!(
            !index.contains(col.id, 2),
            "item 2 was never added — must not appear"
        );

        db.close().unwrap();
    }
}
