use crossbeam::channel::bounded;

use super::*;

fn make_event(id: u64) -> EventRecord {
    EventRecord::signal(id, 1, 1.0, 1_000_000_000)
}

#[test]
fn writer_processes_single_event() {
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(100);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let (reply_tx, reply_rx) = bounded(1);
    tx.send(WalCommand::Append {
        event: make_event(42),
        reply: reply_tx,
    })
    .expect("send should succeed");
    tx.send(WalCommand::Shutdown).expect("send should succeed");

    let handle = std::thread::spawn(move || run_writer(&rx, &config, segment, 1, dedup));

    let seq = reply_rx
        .recv()
        .expect("should receive reply")
        .expect("should be ok");
    assert_eq!(seq, 1);

    handle
        .join()
        .expect("thread should join")
        .expect("writer should succeed");
}

#[test]
fn writer_deduplicates_events() {
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(100);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let event = make_event(42);

    let (reply_tx1, reply_rx1) = bounded(1);
    let (reply_tx2, reply_rx2) = bounded(1);
    tx.send(WalCommand::Append {
        event: event.clone(),
        reply: reply_tx1,
    })
    .expect("send should succeed");
    tx.send(WalCommand::Append {
        event,
        reply: reply_tx2,
    })
    .expect("send should succeed");
    tx.send(WalCommand::Shutdown).expect("send should succeed");

    let handle = std::thread::spawn(move || run_writer(&rx, &config, segment, 1, dedup));

    let seq1 = reply_rx1
        .recv()
        .expect("should receive")
        .expect("should be ok");
    let seq2 = reply_rx2
        .recv()
        .expect("should receive")
        .expect("should be ok");
    assert_eq!(seq1, 1);
    assert_eq!(seq2, 0); // deduplicated

    handle
        .join()
        .expect("thread should join")
        .expect("writer should succeed");
}

#[test]
fn writer_handles_channel_disconnect() {
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(100);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    drop(tx); // Disconnect immediately

    let result = run_writer(&rx, &config, segment, 1, dedup);
    assert!(result.is_ok());
}

#[test]
fn validate_writer_config_rejects_oversized_batch() {
    // batch_size above the wire-format cap must be rejected with a typed error,
    // not accepted then silently crash the writer thread on the first full batch.
    let max = usize::from(MAX_EVENTS_PER_BATCH);
    let err = validate_writer_config(max + 1).expect_err("oversized batch_size must be rejected");
    assert!(
        matches!(err, WalError::InvalidConfig { .. }),
        "expected InvalidConfig, got {err:?}"
    );
}

#[test]
fn validate_writer_config_rejects_zero_batch() {
    let err = validate_writer_config(0).expect_err("zero batch_size must be rejected");
    assert!(matches!(err, WalError::InvalidConfig { .. }));
}

#[test]
fn validate_writer_config_accepts_max_batch() {
    let max = usize::from(MAX_EVENTS_PER_BATCH);
    assert!(validate_writer_config(max).is_ok());
    assert!(validate_writer_config(1).is_ok());
}

#[test]
fn writer_clamps_oversized_batch_size_without_crashing() {
    // Defence-in-depth: even if a WriterConfig is built directly with a
    // batch_size above MAX_EVENTS_PER_BATCH (bypassing open()'s validation), the
    // writer thread must clamp the drain limit and keep persisting events rather
    // than failing to encode and terminating the thread. We push more events
    // than MAX_EVENTS_PER_BATCH so a naive (unclamped) drain would build an
    // unencodable batch.
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(2048);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let oversized = usize::from(MAX_EVENTS_PER_BATCH) * 4;
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: oversized,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let event_count = usize::from(MAX_EVENTS_PER_BATCH) + 50;
    let mut reply_rxs = Vec::with_capacity(event_count);
    for i in 0..event_count as u64 {
        let (reply_tx, reply_rx) = bounded(1);
        tx.send(WalCommand::Append {
            event: make_event(i),
            reply: reply_tx,
        })
        .expect("send should succeed");
        reply_rxs.push(reply_rx);
    }
    tx.send(WalCommand::Shutdown).expect("send should succeed");

    let handle = std::thread::spawn(move || run_writer(&rx, &config, segment, 1, dedup));

    // Every event must resolve to a real sequence number (no thread crash, no
    // dropped reply channels). Clamping yields multiple batches, so sequence
    // numbers stay monotonically increasing across batch boundaries.
    let mut seqs = Vec::with_capacity(event_count);
    for reply_rx in reply_rxs {
        let seq = reply_rx
            .recv()
            .expect("reply channel must not be dropped")
            .expect("write must succeed despite oversized batch_size");
        seqs.push(seq);
    }
    for window in seqs.windows(2) {
        assert!(window[0] < window[1], "seqs not monotonic: {seqs:?}");
    }
    assert_eq!(seqs.len(), event_count);

    handle
        .join()
        .expect("thread should join")
        .expect("writer should succeed, not crash, on oversized batch_size");
}

#[test]
fn flush_batch_error_notifies_callers() {
    // The shared flush routine (used by BOTH the steady loop and the shutdown
    // drain) must notify every waiting caller with an error on write failure,
    // never drop their reply channels. We drive flush_batch directly with the
    // test fault hook armed and assert the caller receives Err, not a Closed.
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let mut segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let (reply_tx, reply_rx) = bounded(1);
    let kept_events = vec![make_event(1)];
    let kept_replies = vec![reply_tx];

    arm_flush_failure();
    let result = flush_batch(&mut segment, &config, 1, &kept_events, kept_replies);

    assert!(result.is_err(), "flush must propagate the injected error");
    // The caller's reply channel must carry the error, not be dropped (which
    // would surface as a RecvError/Closed and hide the real cause).
    let reply = reply_rx
        .recv()
        .expect("reply channel must NOT be dropped on flush error");
    assert!(
        matches!(reply, Err(WalError::Io(_))),
        "caller must receive the write error, got {reply:?}"
    );
}

#[test]
fn shutdown_drain_notifies_callers_on_write_error() {
    // Route appends through the SHUTDOWN DRAIN path (Shutdown sent first, so the
    // main loop breaks immediately and the appends are flushed by the drain
    // tail). With the flush fault armed, the drained caller must still receive
    // an error reply — proving the drain path no longer diverges from the main
    // loop by dropping reply channels on error.
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(16);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    // Shutdown FIRST so the main loop's blocking recv() breaks immediately and
    // the following append is handled by the drain tail.
    tx.send(WalCommand::Shutdown).expect("send should succeed");
    let (reply_tx, reply_rx) = bounded(1);
    tx.send(WalCommand::Append {
        event: make_event(7),
        reply: reply_tx,
    })
    .expect("send should succeed");
    drop(tx);

    // Arm the fault on the WRITER thread (thread-local), then run the writer
    // synchronously on this thread so the armed flag applies.
    arm_flush_failure();
    let result = run_writer(&rx, &config, segment, 1, dedup);
    assert!(result.is_err(), "drain flush error must propagate");

    let reply = reply_rx
        .recv()
        .expect("drain path must NOT drop the reply channel on error");
    assert!(
        matches!(reply, Err(WalError::Io(_))),
        "drained caller must receive the error, got {reply:?}"
    );
}

#[test]
fn steady_state_flush_error_does_not_kill_writer_and_allows_retry() {
    // BLOCKER + wal-3 regression: a transient flush failure in the STEADY-STATE
    // loop must NOT terminate the writer thread, and the failed event must NOT be
    // recorded in the dedup window — so an identical retry is accepted and
    // durably persisted (a real, non-zero seq), never silently swallowed as a
    // duplicate (the seq=0 sentinel).
    //
    // batch_size = 1 makes each event its own batch, so the retry lands in a
    // SEPARATE flush from the failed first attempt while we drive run_writer
    // synchronously on this thread (where the thread-local fault hook applies).
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(16);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 1, // one event per batch, so attempt and retry flush separately
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let event = make_event(99);
    // First attempt: will hit the injected one-shot flush failure.
    let (reply_tx1, reply_rx1) = bounded(1);
    tx.send(WalCommand::Append {
        event: event.clone(),
        reply: reply_tx1,
    })
    .expect("send should succeed");
    // Retry of the SAME event: must be accepted (not deduped) and persisted.
    let (reply_tx2, reply_rx2) = bounded(1);
    tx.send(WalCommand::Append {
        event,
        reply: reply_tx2,
    })
    .expect("send should succeed");
    tx.send(WalCommand::Shutdown).expect("send should succeed");
    drop(tx);

    // Arm the one-shot fault on THIS thread, then run the writer synchronously
    // here so the fault fires inside the first steady-state flush_batch.
    arm_flush_failure();
    let result = run_writer(&rx, &config, segment, 1, dedup);

    // BLOCKER: the writer survived the transient error and exited cleanly.
    assert!(
        result.is_ok(),
        "writer must survive a transient steady-state flush error, got {result:?}"
    );

    // First attempt observed the error (so the caller knows to retry).
    let r1 = reply_rx1.recv().expect("first caller must be notified");
    assert!(
        matches!(r1, Err(WalError::Io(_))),
        "first attempt must see the flush error, got {r1:?}"
    );

    // wal-3: the retry was NOT suppressed as a duplicate — it got a real,
    // non-zero sequence number, meaning it was durably persisted.
    let r2 = reply_rx2
        .recv()
        .expect("retry caller must be notified")
        .expect("retry must persist successfully");
    assert!(
        r2 > 0,
        "retry must get a real sequence number, not the dedup sentinel 0; got {r2}"
    );
}

#[test]
fn truncate_before_never_deletes_active_segment_even_when_fully_checkpointed() {
    // CRITICAL regression (mirrors
    // `compaction::online_never_deletes_active_segment_even_when_fully_checkpointed`,
    // but driven through the writer's `TruncateBefore` command path): the live
    // segment the writer holds open must survive a `truncate_before(seq)` whose
    // `seq` has advanced far past the active segment's `first_seq`. Without the
    // active-segment clamp in `handle_aux_command`, `delete_segments_before`
    // would unlink the very inode this writer keeps appending to, and on Linux
    // every post-truncation, already-fsync'd, acknowledged write would be
    // silently lost on the next open.
    use super::super::reader;
    use super::super::segment::{list_segments, segment_filename};

    let dir = tempfile::tempdir().expect("tempdir creation should succeed");

    // Two segments on disk: an old one (first_seq=1) and the active one
    // (first_seq=100, the maximum — the segment the writer opens and appends to,
    // matching `WalHandle::open`'s `segments.last()` selection).
    for &seq in &[1u64, 100] {
        let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 16 * 1024 * 1024)
            .expect("seed segment open should succeed");
    }
    assert_eq!(
        list_segments(dir.path())
            .expect("list should succeed")
            .len(),
        2,
        "two seed segments expected before truncation"
    );

    let (tx, rx) = bounded(16);
    // Open the writer on the ACTIVE (max-first_seq) segment, exactly as
    // `WalHandle::open` does.
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 16 * 1024 * 1024)
        .expect("active segment open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    // Truncate with a sequence far past BOTH segments' first_seq. A naive
    // `delete_segments_before(1_000)` would unlink the active segment (100);
    // the clamp must hold the floor at min(1_000, 100) = 100, deleting only
    // segment 1.
    let (trunc_tx, trunc_rx) = bounded(1);
    tx.send(WalCommand::TruncateBefore {
        before_seq: 1_000,
        reply: trunc_tx,
    })
    .expect("send truncate should succeed");

    // After truncation, append an event. It is assigned seq=100 (start_seq) and
    // must land durably in the still-open active segment — proving the writer's
    // FD points at a LIVE inode, not an unlinked one.
    let (reply_tx, reply_rx) = bounded(1);
    tx.send(WalCommand::Append {
        event: make_event(7),
        reply: reply_tx,
    })
    .expect("send append should succeed");
    tx.send(WalCommand::Shutdown).expect("send should succeed");
    drop(tx);

    // start_seq = 100 so the post-truncation append is assigned seq 100,
    // matching the active segment's first_seq.
    run_writer(&rx, &config, segment, 100, dedup).expect("writer should succeed");

    // The truncation reply: only the non-active old segment was deleted.
    trunc_rx
        .recv()
        .expect("truncate reply must arrive")
        .expect("truncate must succeed");

    // The active segment (first_seq=100) survived; segment 1 was deleted.
    let remaining = list_segments(dir.path()).expect("list should succeed");
    assert_eq!(remaining.len(), 1, "exactly the active segment must remain");
    assert_eq!(
        remaining[0].0, 100,
        "the active (max-first_seq) segment must be preserved"
    );
    assert!(
        dir.path()
            .join(segment_filename(ShardId::SINGLE, 100))
            .exists(),
        "active segment file must still exist on disk"
    );

    // The append's seq was assigned and acknowledged.
    let seq = reply_rx
        .recv()
        .expect("append reply must arrive")
        .expect("append must succeed");
    assert_eq!(seq, 100, "post-truncation append assigned start_seq");

    // The acknowledged write is durably recoverable — it was written to the
    // live inode, NOT lost to an unlinked one.
    let recovered = reader::recover(dir.path()).expect("recover should succeed");
    assert_eq!(
        recovered.events.len(),
        1,
        "the post-truncation append must survive recovery"
    );
    assert_eq!(
        recovered.events[0].entity_id, 7,
        "the recovered event must be the one appended after truncation"
    );
}

#[test]
fn writer_assigns_monotonic_sequences() {
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let (tx, rx) = bounded(100);
    let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let dedup = DedupWindow::new(Duration::from_secs(30));
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let mut reply_rxs = Vec::new();
    for i in 0..5 {
        let (reply_tx, reply_rx) = bounded(1);
        tx.send(WalCommand::Append {
            event: make_event(i),
            reply: reply_tx,
        })
        .expect("send should succeed");
        reply_rxs.push(reply_rx);
    }
    tx.send(WalCommand::Shutdown).expect("send should succeed");

    let handle = std::thread::spawn(move || run_writer(&rx, &config, segment, 1, dedup));

    let mut seqs = Vec::new();
    for reply_rx in reply_rxs {
        let seq = reply_rx
            .recv()
            .expect("should receive")
            .expect("should be ok");
        seqs.push(seq);
    }

    // Verify monotonically increasing
    for window in seqs.windows(2) {
        assert!(window[0] < window[1], "seqs not monotonic: {seqs:?}");
    }
    assert_eq!(seqs[0], 1);

    handle
        .join()
        .expect("thread should join")
        .expect("writer should succeed");
}

#[test]
fn flush_batch_does_not_panic_on_clock_anomaly() {
    // Regression for CRITICAL 10: flush_batch previously sourced the batch
    // timestamp via SystemTime::now().duration_since(UNIX_EPOCH).expect(...),
    // which panics whenever the wall clock is before 1970 (NTP step-back,
    // dead/uninitialized RTC at boot). On the dedicated writer thread that panic
    // is a permanent write outage. The fix routes the timestamp through the
    // clock-anomaly-safe crate::schema::Timestamp::now(), which saturates to the
    // epoch and logs a warning instead of panicking.
    //
    // We cannot move the real wall clock backwards without new infra, so this
    // test pins down the two load-bearing facts: (1) flush_batch completes
    // without panicking, and (2) the exact helper flush_batch/checkpoint now use
    // saturates a pre-epoch clock to 0 rather than panicking — mirroring the
    // `now_never_panics` regression in schema::timestamp.
    let dir = tempfile::tempdir().expect("tempdir creation should succeed");
    let mut segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
        .expect("open should succeed");
    let config = WriterConfig {
        dir: dir.path().to_path_buf(),
        segment_size: 16 * 1024 * 1024,
        batch_size: 100,
        batch_timeout: Duration::from_millis(10),
        dedup_window: Duration::from_secs(30),
        session_journal_path: None,
        shard_id: ShardId::SINGLE,
        region_id: RegionId::SINGLE,
    };

    let (reply_tx, reply_rx) = bounded(1);
    let kept_events = vec![make_event(1)];
    let kept_replies = vec![reply_tx];

    // flush_batch must succeed (the timestamp step is on its happy path) without
    // panicking — proving the writer thread cannot be killed by the timestamp.
    // flush_batch returns the NEXT sequence number (batch_seq + event_count), so
    // a single event at batch_seq 1 yields 2; the caller's own reply carries the
    // event's assigned seq (batch_seq + i = 1).
    let seq = flush_batch(&mut segment, &config, 1, &kept_events, kept_replies)
        .expect("flush must succeed");
    assert_eq!(seq, 2);
    let reply = reply_rx.recv().expect("reply channel must not be dropped");
    assert_eq!(reply.expect("caller must receive ok"), 1);

    // The timestamp helper that BOTH flush_batch and WalHandle::checkpoint now
    // use is non-panicking and saturates a pre-epoch clock to the epoch. We
    // assert the contract directly: the epoch-saturation target is Timestamp(0),
    // and now() always returns a value rather than `.expect()`-panicking.
    assert_eq!(
        crate::schema::Timestamp::from_nanos(0).as_nanos(),
        0,
        "epoch saturation target must be 0 nanos"
    );
    // Calling now() must always return a value, never panic, on any host clock.
    let _ = crate::schema::Timestamp::now().as_nanos();
}
