//! Write-Ahead Log for signal event durability.
//!
//! The WAL is the durability primitive for signal events. Every view, like,
//! skip, and completion is appended to the WAL before any aggregation occurs.
//! Signal aggregates, decay scores, and windowed counts are derived state
//! that can always be rebuilt from WAL replay.
//!
//! # Architecture
//!
//! - **Batch-oriented**: events are grouped into batches (up to 256 events)
//!   and written as a single atomic unit with one BLAKE3 checksum and one fsync.
//! - **Group commit**: a dedicated writer thread accumulates events from
//!   concurrent callers, forming batches by count or timeout.
//! - **Segment files**: the WAL is split into 16 MB segment files for
//!   efficient truncation after checkpointing.
//! - **Deduplication**: a double-buffered `HashSet<u128>` detects duplicate
//!   events within a configurable time window.
//! - **Crash recovery**: two-phase validation (magic + bounds, then BLAKE3)
//!   with automatic truncation of corrupted tails.

pub mod checkpoint;
pub mod compaction;
pub mod config;
pub mod dedup;
pub mod diagnostics;
pub mod error;
pub mod format;
pub mod reader;
pub mod segment;
pub mod session_journal;
pub mod writer;

pub use config::WalConfig;

use std::fs;
use std::path::PathBuf;

use crossbeam::channel::{Sender, bounded};

use self::dedup::DedupWindow;
use self::error::WalError;
use self::format::{EventRecord, SessionWalEvent};
use self::segment::SegmentWriter;
use self::session_journal::SessionJournal;
use self::writer::{WalCommand, WriterConfig};
use crate::replication::{RegionId, ShardId};

/// Default channel capacity for the writer command channel.
const DEFAULT_CHANNEL_CAPACITY: usize = 10_000;

/// A signal event to be appended to the WAL.
///
/// This is the public write type. It maps 1:1 to the internal
/// `EventRecord` wire format.
#[derive(Debug, Clone, PartialEq)]
pub struct SignalEvent {
    pub entity_id: u64,
    pub signal_type: u8,
    pub weight: f32,
    pub timestamp_nanos: u64,
}

impl From<SignalEvent> for EventRecord {
    fn from(e: SignalEvent) -> Self {
        Self {
            entity_id: e.entity_id,
            signal_type: e.signal_type,
            weight: e.weight,
            timestamp_nanos: e.timestamp_nanos,
        }
    }
}

impl From<EventRecord> for SignalEvent {
    fn from(e: EventRecord) -> Self {
        Self {
            entity_id: e.entity_id,
            signal_type: e.signal_type,
            weight: e.weight,
            timestamp_nanos: e.timestamp_nanos,
        }
    }
}

/// A cloneable, `Send + Sync` sender for WAL append operations.
///
/// Created by [`WalHandle::sender()`]. Allows WAL append to be called
/// from components (e.g. `WalHandleWriter`) that must be `Send + Sync`
/// without sharing the full `WalHandle` (which owns the writer thread).
#[derive(Clone)]
pub struct WalSender {
    tx: Sender<WalCommand>,
}

impl WalSender {
    /// Append a signal event. Blocks until the batch containing this event
    /// has been durably fsynced to disk.
    ///
    /// Returns the assigned monotonic sequence number.
    /// Returns `Ok(0)` if the event was deduplicated.
    ///
    /// # Errors
    ///
    /// Returns `WalError::SendFailed` if the writer thread has panicked.
    pub fn append(&self, event: SignalEvent) -> Result<u64, WalError> {
        let (reply_tx, reply_rx) = bounded(1);
        self.tx
            .send(WalCommand::Append {
                event: event.into(),
                reply: reply_tx,
            })
            .map_err(|_| WalError::SendFailed)?;
        reply_rx.recv().map_err(|_| WalError::SendFailed)?
    }
}

/// Handle to the WAL. Provides the public API for appending events,
/// checkpointing, and truncation.
///
/// Internally manages a writer thread that performs group commit.
/// All public methods are safe to call from multiple threads concurrently.
pub struct WalHandle {
    tx: Sender<WalCommand>,
    thread: Option<std::thread::JoinHandle<Result<(), WalError>>>,
    wal_dir: PathBuf,
}

impl std::fmt::Debug for WalHandle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WalHandle")
            .field("wal_dir", &self.wal_dir)
            .finish_non_exhaustive()
    }
}

impl WalHandle {
    /// Clone the channel sender for use in separate components.
    ///
    /// The returned [`WalSender`] is `Clone + Send + Sync` and can be used
    /// from multiple threads concurrently to append events without holding
    /// a reference to the full `WalHandle`.
    #[must_use]
    pub fn sender(&self) -> WalSender {
        WalSender {
            tx: self.tx.clone(),
        }
    }

    /// Return the number of pending commands in the writer channel.
    ///
    /// O(1) operation. Used by the backpressure check in `TidalDb::signal()`
    /// to detect queue saturation before enqueuing.
    #[must_use]
    pub fn channel_len(&self) -> usize {
        self.tx.len()
    }

    /// Open the WAL directory, recover from any crash, and return a ready handle.
    ///
    /// Returns the handle, a list of replayed signal events since the last
    /// checkpoint, and a list of recovered session journal events (for the
    /// session materializer to process).
    ///
    /// # Errors
    ///
    /// Returns `WalError` on I/O failure or unrecoverable corruption.
    // Config is consumed by value: fields are moved into WriterConfig for the spawned thread.
    #[allow(clippy::needless_pass_by_value)]
    pub fn open(
        config: WalConfig,
    ) -> Result<(Self, Vec<SignalEvent>, Vec<SessionWalEvent>), WalError> {
        let wal_dir = config.wal_dir();
        fs::create_dir_all(&wal_dir)?;

        // Recover from any previous crash
        let recovery = reader::recover(&wal_dir)?;
        let replayed_events: Vec<SignalEvent> = recovery
            .events
            .iter()
            .cloned()
            .map(SignalEvent::from)
            .collect();
        // Sequence 0 is reserved as the dedup sentinel (returned for duplicate events).
        // Real events always get seq >= 1.
        let next_seq = recovery.next_seq.max(1);

        // Recover session journal events.
        let session_journal_path = wal_dir.join(session_journal::SESSION_JOURNAL_FILENAME);
        let session_events = SessionJournal::recover(&session_journal_path)?;

        // Initialize dedup window from replayed events
        let mut dedup = DedupWindow::new(config.dedup_window);
        dedup.populate_from_events(recovery.events);

        // Open (or create) the current segment
        // Find the segment that should receive new writes
        let segments = segment::list_segments(&wal_dir)?;
        let segment_first_seq = if let Some((last_seg_seq, _)) = segments.last() {
            *last_seg_seq
        } else {
            // No segments exist yet.
            next_seq
        };

        let segment = SegmentWriter::open(
            &wal_dir,
            ShardId::SINGLE,
            segment_first_seq,
            config.segment_size,
        )?;

        // Create the command channel
        let (tx, rx) = bounded(DEFAULT_CHANNEL_CAPACITY);

        let writer_config = WriterConfig {
            dir: wal_dir.clone(),
            segment_size: config.segment_size,
            batch_size: config.batch_size,
            batch_timeout: config.batch_timeout,
            dedup_window: config.dedup_window,
            session_journal_path: Some(session_journal_path),
            // Single-node default. Multi-shard deployments override via NodeConfig
            // before open() is called.
            shard_id: ShardId::SINGLE,
            region_id: RegionId::SINGLE,
        };

        // Spawn the writer thread
        let thread = std::thread::Builder::new()
            .name("tidaldb-wal-writer".into())
            .spawn(move || writer::run_writer(&rx, &writer_config, segment, next_seq, dedup))
            .map_err(|e| WalError::Io(std::io::Error::other(e)))?;

        let segment_count = segments.len();
        let replayed_count = replayed_events.len();
        let session_event_count = session_events.len();

        tracing::info!(
            segments = segment_count,
            replayed_events = replayed_count,
            session_events = session_event_count,
            next_seq,
            "wal: recovery complete"
        );

        Ok((
            Self {
                tx,
                thread: Some(thread),
                wal_dir,
            },
            replayed_events,
            session_events,
        ))
    }

    /// Append a signal event. Blocks until the batch containing this event
    /// has been durably fsynced to disk.
    ///
    /// Returns the assigned monotonic sequence number.
    /// Returns `Ok(0)` if the event was deduplicated (already seen).
    ///
    /// # Errors
    ///
    /// Returns `WalError::Closed` if the WAL has been shut down.
    /// Returns `WalError::SendFailed` if the writer thread has panicked.
    pub fn append(&self, event: SignalEvent) -> Result<u64, WalError> {
        let (reply_tx, reply_rx) = bounded(1);
        self.tx
            .send(WalCommand::Append {
                event: event.into(),
                reply: reply_tx,
            })
            .map_err(|_| WalError::SendFailed)?;

        reply_rx.recv().map_err(|_| WalError::SendFailed)?
    }

    // ── Session journal methods ────────────────────────────────────────────
    //
    // These send fire-and-forget session commands to the writer thread.
    // The writer thread writes them to the session journal (separate file)
    // with per-write fsync. Errors are swallowed: session WAL writes are
    // best-effort; in-memory state is the source of truth.

    /// Record a session start in the session journal.
    ///
    /// # Errors
    ///
    /// Returns `WalError::SendFailed` if the writer thread has exited.
    pub fn session_start(
        &self,
        session_id: u64,
        user_id: u64,
        started_at_ns: u64,
        agent_id: &str,
        policy_name: &str,
    ) -> Result<(), WalError> {
        self.tx
            .send(WalCommand::SessionStart {
                session_id,
                user_id,
                started_at_ns,
                agent_id: agent_id.to_owned(),
                policy_name: policy_name.to_owned(),
            })
            .map_err(|_| WalError::SendFailed)
    }

    /// Record a session signal in the session journal.
    ///
    /// # Errors
    ///
    /// Returns `WalError::SendFailed` if the writer thread has exited.
    #[allow(clippy::too_many_arguments)]
    pub fn session_signal(
        &self,
        session_id: u64,
        entity_id: u64,
        weight: f32,
        ts_ns: u64,
        signal_name: &str,
        annotation: Option<&str>,
        session_seqno: Option<u64>,
        idempotency_key: Option<u128>,
    ) -> Result<(), WalError> {
        self.tx
            .send(WalCommand::SessionSignal {
                session_id,
                entity_id,
                weight,
                ts_ns,
                signal_name: signal_name.to_owned(),
                annotation: annotation.map(str::to_owned),
                session_seqno,
                idempotency_key,
            })
            .map_err(|_| WalError::SendFailed)
    }

    /// Record a session close in the session journal.
    ///
    /// # Errors
    ///
    /// Returns `WalError::SendFailed` if the writer thread has exited.
    pub fn session_close(&self, session_id: u64) -> Result<(), WalError> {
        self.tx
            .send(WalCommand::SessionClose { session_id })
            .map_err(|_| WalError::SendFailed)
    }

    /// Write a checkpoint marker at the given sequence number.
    ///
    /// Called by the signal materializer (P1.4) after flushing in-memory
    /// signal state to durable storage.
    ///
    /// # Errors
    ///
    /// Returns `WalError::Io` on filesystem failure.
    ///
    /// # Panics
    ///
    /// Panics if the system clock is before the Unix epoch.
    pub fn checkpoint(&self, seq: u64) -> Result<(), WalError> {
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system clock is before Unix epoch")
            .as_nanos();
        #[allow(clippy::cast_possible_truncation)]
        let ts_u64 = ts as u64;
        checkpoint::CheckpointManager::write(&self.wal_dir, seq, ts_u64)
    }

    /// Delete WAL segments whose events are all before `seq`.
    ///
    /// The truncation runs inside the writer thread to avoid racing with
    /// concurrent writes to segment files. Blocks until the writer thread
    /// has completed the deletion.
    ///
    /// # Errors
    ///
    /// Returns `WalError::Io` on filesystem failure.
    /// Returns `WalError::Closed` if the writer thread has exited.
    pub fn truncate_before(&self, seq: u64) -> Result<(), WalError> {
        let (reply_tx, reply_rx) = crossbeam::channel::bounded(1);
        self.tx
            .send(WalCommand::TruncateBefore {
                before_seq: seq,
                reply: reply_tx,
            })
            .map_err(|_| WalError::Closed)?;
        reply_rx.recv().map_err(|_| WalError::Closed)?
    }

    /// Graceful shutdown: signal the writer thread to flush remaining events,
    /// fsync, and exit. Blocks until the writer thread terminates.
    ///
    /// # Errors
    ///
    /// Returns `WalError::ShutdownFailed` if the writer thread panicked.
    pub fn shutdown(mut self) -> Result<(), WalError> {
        // Send shutdown command (ignore send error -- writer may already be gone)
        let _ = self.tx.send(WalCommand::Shutdown);

        if let Some(thread) = self.thread.take() {
            match thread.join() {
                Ok(result) => result?,
                Err(_) => return Err(WalError::ShutdownFailed),
            }
        }
        Ok(())
    }
}

impl Drop for WalHandle {
    fn drop(&mut self) {
        // Best-effort shutdown if not already shut down
        if self.thread.is_some() {
            let _ = self.tx.send(WalCommand::Shutdown);
            if let Some(thread) = self.thread.take() {
                let _ = thread.join();
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    fn test_config(dir: &std::path::Path) -> WalConfig {
        WalConfig {
            dir: dir.to_path_buf(),
            ..WalConfig::default()
        }
    }

    fn make_event(id: u64) -> SignalEvent {
        SignalEvent {
            entity_id: id,
            signal_type: 1,
            weight: 1.0,
            timestamp_nanos: id * 1_000_000_000,
        }
    }

    #[test]
    fn open_creates_wal_directory() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let config = test_config(dir.path());
        let wal_dir = config.wal_dir();

        let (handle, replayed, _session_events) =
            WalHandle::open(config).expect("open should succeed");
        assert!(wal_dir.exists());
        assert!(replayed.is_empty());

        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn append_returns_sequence_number() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let config = test_config(dir.path());

        let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
        let seq = handle.append(make_event(1)).expect("append should succeed");
        // Sequence is always non-negative (u64), just verify we got a value
        let _ = seq;
        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn append_multiple_monotonic() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let config = test_config(dir.path());

        let (handle, _, _) = WalHandle::open(config).expect("open should succeed");

        let mut seqs = Vec::new();
        for i in 1..=10 {
            let seq = handle.append(make_event(i)).expect("append should succeed");
            seqs.push(seq);
        }

        // Filter out dedup seq=0 (should be none for unique events)
        let non_zero: Vec<u64> = seqs.iter().copied().filter(|&s| s > 0).collect();
        for window in non_zero.windows(2) {
            assert!(window[0] < window[1], "not monotonic: {non_zero:?}");
        }

        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn dedup_returns_zero() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let config = test_config(dir.path());

        let (handle, _, _) = WalHandle::open(config).expect("open should succeed");

        let event = make_event(42);
        let seq1 = handle
            .append(event.clone())
            .expect("first append should succeed");
        let seq2 = handle.append(event).expect("second append should succeed");

        assert!(seq1 > 0);
        assert_eq!(seq2, 0); // deduplicated

        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn checkpoint_writes_file() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let config = test_config(dir.path());
        let wal_dir = config.wal_dir();

        let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
        handle.append(make_event(1)).expect("append should succeed");
        handle.checkpoint(1).expect("checkpoint should succeed");

        let cp = checkpoint::CheckpointManager::read(&wal_dir).expect("read should succeed");
        assert!(cp.is_some());
        let (seq, _ts) = cp.expect("checkpoint should exist");
        assert_eq!(seq, 1);

        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn close_and_reopen_continues_sequence() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        // First session
        let config = test_config(dir.path());
        let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
        let mut last_seq = 0;
        for i in 1..=5 {
            let seq = handle.append(make_event(i)).expect("append should succeed");
            if seq > last_seq {
                last_seq = seq;
            }
        }
        handle.shutdown().expect("shutdown should succeed");

        // Second session
        let config = test_config(dir.path());
        let (handle, replayed, _session_events) =
            WalHandle::open(config).expect("reopen should succeed");
        assert_eq!(replayed.len(), 5);

        // New events should get higher sequence numbers
        let new_seq = handle
            .append(make_event(100))
            .expect("append should succeed");
        assert!(
            new_seq > last_seq,
            "new_seq {new_seq} should be > last_seq {last_seq}"
        );

        handle.shutdown().expect("shutdown should succeed");
    }

    #[test]
    fn signal_event_converts_to_event_record() {
        let signal = make_event(42);
        let record: EventRecord = signal.clone().into();
        assert_eq!(record.entity_id, 42);
        assert_eq!(record.signal_type, 1);
        assert_eq!(record.weight.to_bits(), signal.weight.to_bits());
    }

    #[test]
    fn event_record_converts_to_signal_event() {
        let record = EventRecord {
            entity_id: 42,
            signal_type: 1,
            weight: 2.5,
            timestamp_nanos: 1_000_000_000,
        };
        let signal: SignalEvent = record.into();
        assert_eq!(signal.entity_id, 42);
        assert_eq!(signal.signal_type, 1);
        assert_eq!(signal.weight.to_bits(), 2.5_f32.to_bits());
    }
}
