use std::{sync::Arc, time::Duration};

use super::*;
use crate::{
    entities::HardNegIndex,
    replication::{
        crdt::{CrdtSignalState, LWWRegister, hlc::HlcTimestamp},
        shard::ShardId,
    },
    schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
    signals::{SignalLedger, SignalTypeId, ledger::types::NoopWalWriter},
};

/// 7-day half-life lambda (matches the standard "view" signal).
const LAMBDA: f64 = std::f64::consts::LN_2 / (7.0 * 24.0 * 3600.0);

fn test_schema() -> crate::schema::Schema {
    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    builder.build().unwrap()
}

fn make_ledger() -> Arc<SignalLedger> {
    Arc::new(SignalLedger::new(test_schema(), Box::new(NoopWalWriter)))
}

fn make_engine() -> (ReconciliationEngine, Arc<SignalLedger>, Arc<HardNegIndex>) {
    let ledger = make_ledger();
    let hard_neg = Arc::new(HardNegIndex::new());
    let engine = ReconciliationEngine::new(Arc::clone(&ledger), Arc::clone(&hard_neg));
    (engine, ledger, hard_neg)
}

fn ts(wall_ns: u64, logical: u32, node_id: u16) -> HlcTimestamp {
    HlcTimestamp {
        wall_ns,
        logical,
        node_id,
    }
}

// -- StateSnapshot construction --

#[test]
fn snapshot_empty_by_default() {
    let snap = StateSnapshot::new();
    assert_eq!(snap.signal_count(), 0);
    assert_eq!(snap.hardneg_count(), 0);
}

#[test]
fn snapshot_add_and_retrieve_signal() {
    let mut snap = StateSnapshot::new();
    let entity = EntityId::new(1);
    let sig_id = SignalTypeId::new(0);
    let state = CrdtSignalState::new(LAMBDA);
    snap.add_signal_state(entity, sig_id, state);

    assert_eq!(snap.signal_count(), 1);
    assert!(snap.signal_state((entity, sig_id)).is_some());
    assert!(snap.signal_state((EntityId::new(99), sig_id)).is_none());
}

#[test]
fn snapshot_add_and_retrieve_hardneg() {
    let mut snap = StateSnapshot::new();
    let user = EntityId::new(10);
    let item = EntityId::new(20);
    let mut reg = LWWRegister::empty();
    reg.write(HardNegAction::Hide, ts(1000, 0, 0));
    snap.add_hardneg_register(user, item, reg);

    assert_eq!(snap.hardneg_count(), 1);
    let retrieved = snap.hardneg_register((user, item)).unwrap();
    assert_eq!(retrieved.get(), Some(&HardNegAction::Hide));
}

// -- ReconciliationEngine::plan() --

#[test]
fn plan_empty_snapshots_produces_empty_plan() {
    let (engine, _, _) = make_engine();
    let plan = engine.plan(&StateSnapshot::new(), &StateSnapshot::new());
    assert!(plan.is_empty());
    assert_eq!(plan.operation_count(), 0);
}

#[test]
fn plan_merges_disjoint_signal_states() {
    let (engine, _, _) = make_engine();
    let entity = EntityId::new(1);
    let sig_id = SignalTypeId::new(0);
    let t = 1_000_000_000u64;

    // Local: node 0 contributed 1.0 at t
    let mut local_snap = StateSnapshot::new();
    let mut local_state = CrdtSignalState::new(LAMBDA);
    local_state.on_signal(ShardId(0), 1.0, t);
    local_snap.add_signal_state(entity, sig_id, local_state);

    // Remote: node 1 contributed 2.0 at t
    let mut remote_snap = StateSnapshot::new();
    let mut remote_state = CrdtSignalState::new(LAMBDA);
    remote_state.on_signal(ShardId(1), 2.0, t);
    remote_snap.add_signal_state(entity, sig_id, remote_state);

    let plan = engine.plan(&local_snap, &remote_snap);
    assert_eq!(plan.signal_merges.len(), 1);
    assert_eq!(plan.hardneg_resolutions.len(), 0);

    // Merged score at t should be ~3.0 (1.0 + 2.0, both at same time)
    let merged_score = plan.signal_merges[0].merged_state.decay_score(t);
    assert!(
        (merged_score - 3.0).abs() < 1e-6,
        "merged score {merged_score} should be ~3.0"
    );
}

#[test]
fn plan_includes_single_sided_signal_state() {
    let (engine, _, _) = make_engine();
    let entity = EntityId::new(1);
    let sig_id = SignalTypeId::new(0);
    let t = 1_000_000_000u64;

    // Only local has state.
    let mut local_snap = StateSnapshot::new();
    let mut local_state = CrdtSignalState::new(LAMBDA);
    local_state.on_signal(ShardId(0), 5.0, t);
    local_snap.add_signal_state(entity, sig_id, local_state);

    let remote_snap = StateSnapshot::new(); // empty

    let plan = engine.plan(&local_snap, &remote_snap);
    assert_eq!(plan.signal_merges.len(), 1);
    let merged_score = plan.signal_merges[0].merged_state.decay_score(t);
    assert!(
        (merged_score - 5.0).abs() < 1e-6,
        "single-sided score {merged_score} should be ~5.0"
    );
}

#[test]
fn plan_hardneg_lww_resolution_hide_wins_higher_hlc() {
    let (engine, _, _) = make_engine();
    let user = EntityId::new(10);
    let item = EntityId::new(20);

    // Local: hide at t=1000
    let mut local_snap = StateSnapshot::new();
    let mut local_reg = LWWRegister::empty();
    local_reg.write(HardNegAction::Hide, ts(1000, 0, 0));
    local_snap.add_hardneg_register(user, item, local_reg);

    // Remote: unhide at t=500 (earlier)
    let mut remote_snap = StateSnapshot::new();
    let mut remote_reg = LWWRegister::empty();
    remote_reg.write(HardNegAction::Unhide, ts(500, 0, 1));
    remote_snap.add_hardneg_register(user, item, remote_reg);

    let plan = engine.plan(&local_snap, &remote_snap);
    assert_eq!(plan.hardneg_resolutions.len(), 1);
    assert_eq!(
        plan.hardneg_resolutions[0].action,
        Some(HardNegAction::Hide),
        "hide at t=1000 should beat unhide at t=500"
    );
}

#[test]
fn plan_hardneg_lww_resolution_unhide_wins_higher_hlc() {
    let (engine, _, _) = make_engine();
    let user = EntityId::new(10);
    let item = EntityId::new(20);

    // Local: hide at t=500
    let mut local_snap = StateSnapshot::new();
    let mut local_reg = LWWRegister::empty();
    local_reg.write(HardNegAction::Hide, ts(500, 0, 0));
    local_snap.add_hardneg_register(user, item, local_reg);

    // Remote: unhide at t=1000 (later)
    let mut remote_snap = StateSnapshot::new();
    let mut remote_reg = LWWRegister::empty();
    remote_reg.write(HardNegAction::Unhide, ts(1000, 0, 1));
    remote_snap.add_hardneg_register(user, item, remote_reg);

    let plan = engine.plan(&local_snap, &remote_snap);
    assert_eq!(plan.hardneg_resolutions.len(), 1);
    assert_eq!(
        plan.hardneg_resolutions[0].action,
        Some(HardNegAction::Unhide),
        "unhide at t=1000 should beat hide at t=500"
    );
}

// -- ReconciliationEngine::apply() --

#[test]
fn apply_signal_merge_updates_ledger() {
    use crate::schema::Timestamp;

    let (engine, ledger, _) = make_engine();
    let entity = EntityId::new(42);
    let sig_id = SignalTypeId::new(0); // "view" is alphabetically first (only signal)
    let t = Timestamp::now().as_nanos();

    // Build a merged state with two nodes using a recent timestamp.
    let mut merged = CrdtSignalState::new(LAMBDA);
    merged.on_signal(ShardId(0), 3.0, t);
    merged.on_signal(ShardId(1), 7.0, t);

    let plan = MergePlan {
        signal_merges: vec![SignalMergeOp {
            entity_id: entity,
            signal_type_id: sig_id,
            merged_state: merged,
        }],
        hardneg_resolutions: vec![],
    };

    engine.apply(&plan).unwrap();

    // Verify the ledger entry was created and has a positive score. Map the
    // dashmap guard to the stored score so it is dropped before the asserts.
    let score = ledger
        .entries()
        .get(&(entity, sig_id))
        .map(|entry| entry.hot.stored_score(0));
    assert!(
        score.is_some(),
        "ledger should have an entry for the entity"
    );
    let score = score.unwrap();
    assert!(score > 0.0, "stored score {score} should be positive");
}

#[test]
fn apply_hardneg_hide_adds_to_index() {
    let (engine, _, hard_neg) = make_engine();
    let user = EntityId::new(10);
    let item = EntityId::new(20);

    let plan = MergePlan {
        signal_merges: vec![],
        hardneg_resolutions: vec![HardNegResolutionOp {
            user_id: user,
            item_id: item,
            action: Some(HardNegAction::Hide),
        }],
    };

    engine.apply(&plan).unwrap();
    assert!(
        hard_neg.is_negative(10, 20),
        "item 20 should be hidden for user 10"
    );
}

#[test]
fn apply_hardneg_unhide_removes_from_index() {
    let (engine, _, hard_neg) = make_engine();

    // Pre-populate a hard negative.
    hard_neg.add(10, 20);
    assert!(hard_neg.is_negative(10, 20));

    let plan = MergePlan {
        signal_merges: vec![],
        hardneg_resolutions: vec![HardNegResolutionOp {
            user_id: EntityId::new(10),
            item_id: EntityId::new(20),
            action: Some(HardNegAction::Unhide),
        }],
    };

    engine.apply(&plan).unwrap();
    assert!(
        !hard_neg.is_negative(10, 20),
        "item 20 should no longer be hidden for user 10"
    );
}

#[test]
fn apply_is_idempotent() {
    use crate::schema::Timestamp;

    let (engine, ledger, hard_neg) = make_engine();
    let entity = EntityId::new(1);
    let sig_id = SignalTypeId::new(0);
    let t = Timestamp::now().as_nanos();

    let mut merged = CrdtSignalState::new(LAMBDA);
    merged.on_signal(ShardId(0), 5.0, t);

    let plan = MergePlan {
        signal_merges: vec![SignalMergeOp {
            entity_id: entity,
            signal_type_id: sig_id,
            merged_state: merged,
        }],
        hardneg_resolutions: vec![HardNegResolutionOp {
            user_id: EntityId::new(10),
            item_id: EntityId::new(20),
            action: Some(HardNegAction::Hide),
        }],
    };

    // Apply twice.
    engine.apply(&plan).unwrap();
    let score_first = ledger
        .entries()
        .get(&(entity, sig_id))
        .unwrap()
        .hot
        .stored_score(0);

    engine.apply(&plan).unwrap();
    let score_second = ledger
        .entries()
        .get(&(entity, sig_id))
        .unwrap()
        .hot
        .stored_score(0);

    // Scores should be very close (not exactly equal because Timestamp::now()
    // advances between the two apply calls, causing slightly different decay).
    // But the key invariant is that the second apply does not double the score.
    let ratio = score_second / score_first;
    assert!(
        (ratio - 1.0).abs() < 0.01,
        "idempotent apply: ratio {ratio} should be ~1.0 (first={score_first}, second={score_second})"
    );

    // Hard-neg should still be set (idempotent add).
    assert!(hard_neg.is_negative(10, 20));
}

// -- HLC-monotonic hard-negative convergence through the live reconcile path --
//
// These three tests re-express the convergence scenarios formerly covered by
// `HardNegIndex::apply_replication_hide/unhide` (removed during the M0-M10
// remediation -- it was dead per-index HLC code never wired to production).
// HLC-monotonic hide/unhide convergence now lives in exactly one place: the
// `LWWRegister<HardNegAction>` carried in `StateSnapshot`, resolved by
// `ReconciliationEngine::plan` and driven into `HardNegIndex` by `apply`
// (Hide -> add, Unhide -> remove). Each test exercises that LIVE path end to
// end -- build snapshots, `plan`, `apply` against a real `Arc<HardNegIndex>`,
// then assert `is_negative` -- so the convergence guarantee is checked through
// the wiring operators actually depend on, not through the CRDT primitive in
// isolation (that is covered by the LWWRegister property tests).

/// (a) Lower-HLC unhide loses. A hide at t=100 and an unhide at t=50 converge
/// so the item REMAINS hard-negative: the older unhide cannot revive the item.
#[test]
fn reconcile_lower_hlc_unhide_loses_item_stays_hidden() {
    let (engine, _, hard_neg) = make_engine();
    let user = EntityId::new(10);
    let item = EntityId::new(20);

    // Local node: hide at t=100 (the most recent action).
    let mut local_snap = StateSnapshot::new();
    let mut hide_reg = LWWRegister::empty();
    hide_reg.write(HardNegAction::Hide, ts(100, 0, 0));
    local_snap.add_hardneg_register(user, item, hide_reg);

    // Remote node: unhide at t=50 (causally earlier -- must lose).
    let mut remote_snap = StateSnapshot::new();
    let mut unhide_reg = LWWRegister::empty();
    unhide_reg.write(HardNegAction::Unhide, ts(50, 0, 1));
    remote_snap.add_hardneg_register(user, item, unhide_reg);

    let plan = engine.plan(&local_snap, &remote_snap);
    engine.apply(&plan).unwrap();

    assert!(
        hard_neg.is_negative(10, 20),
        "hide@t=100 must beat unhide@t=50; item stays hard-negative"
    );
}

/// (b) Higher-HLC unhide wins. A hide at t=50 and an unhide at t=200 converge
/// so the item is NOT hard-negative: the newer unhide reverses the older hide.
#[test]
fn reconcile_higher_hlc_unhide_wins_item_cleared() {
    let (engine, _, hard_neg) = make_engine();
    let user = EntityId::new(10);
    let item = EntityId::new(20);

    // Local node: hide at t=50.
    let mut local_snap = StateSnapshot::new();
    let mut hide_reg = LWWRegister::empty();
    hide_reg.write(HardNegAction::Hide, ts(50, 0, 0));
    local_snap.add_hardneg_register(user, item, hide_reg);

    // Remote node: unhide at t=200 (the most recent action -- must win).
    let mut remote_snap = StateSnapshot::new();
    let mut unhide_reg = LWWRegister::empty();
    unhide_reg.write(HardNegAction::Unhide, ts(200, 0, 1));
    remote_snap.add_hardneg_register(user, item, unhide_reg);

    let plan = engine.plan(&local_snap, &remote_snap);
    engine.apply(&plan).unwrap();

    assert!(
        !hard_neg.is_negative(10, 20),
        "unhide@t=200 must beat hide@t=50; item is cleared"
    );
}

/// (c) Multi-shard hide union resolves to the max-HLC hide; an unhide only
/// clears the item if its HLC strictly exceeds that maximum.
///
/// Three independent nodes each hide the same (user, item) during a partition
/// at t=10, t=30 and t=20. Whichever order they reconcile in, LWW keeps the
/// hide with the maximum HLC (t=30) -- this is the "union / max-HLC" property:
/// the surviving action is the latest hide regardless of which shard authored
/// it. We then prove the comparison is against that maximum, not the minimum or
/// any single shard's value:
///   - an unhide at t=25 (< max hide 30) is causally earlier and does NOT clear
///     the item;
///   - an unhide at t=40 (> max hide 30) is causally later and DOES clear it.
#[test]
fn reconcile_multi_shard_hide_union_resolves_to_max_hlc() {
    // Three hides from distinct nodes, deliberately out of HLC order so the
    // reconcile path -- not the test's construction order -- must pick the max.
    // Distinct node_ids keep the timestamps totally ordered with no ties.
    let mut shard_a = StateSnapshot::new(); // hide @ t=10
    let mut reg_a = LWWRegister::empty();
    reg_a.write(HardNegAction::Hide, ts(10, 0, 0));
    shard_a.add_hardneg_register(EntityId::new(10), EntityId::new(20), reg_a);

    let mut shard_b = StateSnapshot::new(); // hide @ t=30 (the maximum)
    let mut reg_b = LWWRegister::empty();
    reg_b.write(HardNegAction::Hide, ts(30, 0, 1));
    shard_b.add_hardneg_register(EntityId::new(10), EntityId::new(20), reg_b);

    let mut shard_c = StateSnapshot::new(); // hide @ t=20
    let mut reg_c = LWWRegister::empty();
    reg_c.write(HardNegAction::Hide, ts(20, 0, 2));
    shard_c.add_hardneg_register(EntityId::new(10), EntityId::new(20), reg_c);

    // Sub-case 1: unhide @ t=25 < max hide (30) -> item STAYS hidden.
    {
        let (engine, _, hard_neg) = make_engine();

        // Fold the three shard hides together through the live reconcile path,
        // then apply against the index. `plan` returns the LWW-resolved action;
        // re-seeding the local snapshot from it folds in shard order-independently.
        let union = fold_hardneg_shards(&engine, &[&shard_a, &shard_b, &shard_c]);

        let mut unhide_snap = StateSnapshot::new();
        let mut unhide_reg = LWWRegister::empty();
        unhide_reg.write(HardNegAction::Unhide, ts(25, 0, 3));
        unhide_snap.add_hardneg_register(EntityId::new(10), EntityId::new(20), unhide_reg);

        let plan = engine.plan(&union, &unhide_snap);
        engine.apply(&plan).unwrap();

        assert!(
            hard_neg.is_negative(10, 20),
            "unhide@t=25 < max hide@t=30; item must stay hard-negative"
        );
    }

    // Sub-case 2: unhide @ t=40 > max hide (30) -> item is CLEARED.
    {
        let (engine, _, hard_neg) = make_engine();
        let union = fold_hardneg_shards(&engine, &[&shard_a, &shard_b, &shard_c]);

        let mut unhide_snap = StateSnapshot::new();
        let mut unhide_reg = LWWRegister::empty();
        unhide_reg.write(HardNegAction::Unhide, ts(40, 0, 3));
        unhide_snap.add_hardneg_register(EntityId::new(10), EntityId::new(20), unhide_reg);

        let plan = engine.plan(&union, &unhide_snap);
        engine.apply(&plan).unwrap();

        assert!(
            !hard_neg.is_negative(10, 20),
            "unhide@t=40 > max hide@t=30; item must be cleared"
        );
    }
}

/// Fold a set of single-(user,item) hard-negative snapshots into one snapshot
/// carrying the LWW-resolved register, driving each pairwise merge through the
/// live `ReconciliationEngine::plan` so the union reflects exactly what the
/// reconcile path produces (max-HLC wins). All shards must agree on the
/// (user, item) key; the resolved action is read back from the plan and
/// re-materialized as an `LWWRegister` at the winning timestamp.
fn fold_hardneg_shards(engine: &ReconciliationEngine, shards: &[&StateSnapshot]) -> StateSnapshot {
    let mut acc = StateSnapshot::new();
    for shard in shards {
        let plan = engine.plan(&acc, shard);
        // Read the winning HLC from the PRE-fold accumulator and this shard
        // before replacing `acc`, then rebuild `acc` from the resolved plan so
        // the folded register keeps the resolved action AND its true max-HLC --
        // later folds and the final unhide compare against that real timestamp,
        // not a synthetic one.
        let mut next = StateSnapshot::new();
        for op in &plan.hardneg_resolutions {
            let key = (op.user_id, op.item_id);
            let winning_ts = acc_winning_ts(&acc, shard, key);
            if let (Some(action), Some(winning_ts)) = (op.action.clone(), winning_ts) {
                let mut reg = LWWRegister::empty();
                reg.write(action, winning_ts);
                next.add_hardneg_register(op.user_id, op.item_id, reg);
            }
        }
        acc = next;
    }
    acc
}

/// Resolve the winning HLC timestamp for `key` across the two snapshots feeding
/// a fold step, mirroring the LWW comparison the reconcile path performs.
fn acc_winning_ts(
    acc: &StateSnapshot,
    shard: &StateSnapshot,
    key: (EntityId, EntityId),
) -> Option<HlcTimestamp> {
    let lhs = acc.hardneg_register(key).and_then(LWWRegister::timestamp);
    let rhs = shard.hardneg_register(key).and_then(LWWRegister::timestamp);
    match (lhs, rhs) {
        (Some(a), Some(b)) => Some(a.max(b)),
        (Some(a), None) => Some(a),
        (None, b) => b,
    }
}

// -- CrdtSignalState::from_node_contribution --

#[test]
fn from_node_contribution_produces_correct_score() {
    let state = CrdtSignalState::from_node_contribution(ShardId(0), 10.0, 1_000_000_000, 0, LAMBDA);
    // At the same timestamp, score should be exactly 10.0.
    let score = state.decay_score(1_000_000_000);
    assert!(
        (score - 10.0).abs() < 1e-10,
        "from_node_contribution score {score} should be 10.0"
    );
}

#[test]
fn from_node_contribution_merges_with_other_nodes() {
    let a = CrdtSignalState::from_node_contribution(ShardId(0), 3.0, 1_000_000_000, 0, LAMBDA);
    let b = CrdtSignalState::from_node_contribution(ShardId(1), 7.0, 1_000_000_000, 0, LAMBDA);
    let mut merged = a;
    merged.merge(&b);
    let score = merged.decay_score(1_000_000_000);
    assert!(
        (score - 10.0).abs() < 1e-10,
        "merged score {score} should be 10.0"
    );
}
