use std::path::PathBuf;
use std::time::{Duration, Instant};

use crossbeam::channel::Receiver;

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

/// Commands sent from `WalHandle` to the writer thread.
pub enum WalCommand {
    /// Append a signal event. The reply channel receives the assigned
    /// sequence number (or an error) once the batch containing this
    /// event has been durably fsynced.
    Append {
        event: EventRecord,
        reply: crossbeam::channel::Sender<Result<u64, WalError>>,
    },
    /// Delete segments whose first sequence number is less than `before_seq`.
    /// Runs inside the writer thread to avoid racing with concurrent writes.
    TruncateBefore {
        before_seq: u64,
        reply: crossbeam::channel::Sender<Result<(), WalError>>,
    },
    /// Graceful shutdown: flush remaining events and exit.
    Shutdown,
    // ── Session lifecycle commands ────────────────────────────────────────
    // These are fire-and-forget (no reply channel). They bypass the signal
    // batch system and write directly to the session journal with fsync.
    /// Record that a session was started.
    SessionStart {
        session_id: u64,
        user_id: u64,
        started_at_ns: u64,
        agent_id: String,
        policy_name: String,
    },
    /// Record that a signal was written within a session.
    SessionSignal {
        session_id: u64,
        entity_id: u64,
        weight: f32,
        ts_ns: u64,
        signal_name: String,
        annotation: Option<String>,
        /// Monotonic sequence number for this write (used for idempotent replay).
        /// `None` for legacy writes that predate the seqno mechanism.
        session_seqno: Option<u64>,
        /// BLAKE3-derived idempotency key for duplicate suppression.
        /// `None` for legacy writes.
        idempotency_key: Option<u128>,
    },
    /// Record that a session was closed.
    SessionClose { session_id: u64 },
}

/// Configuration for the group commit writer.
pub struct WriterConfig {
    pub dir: PathBuf,
    pub segment_size: u64,
    pub batch_size: usize,
    pub batch_timeout: Duration,
    pub dedup_window: Duration,
    /// Path for the session journal file (optional; `None` in ephemeral mode).
    pub session_journal_path: Option<PathBuf>,
    /// Shard identity for this writer. Written into every batch header so
    /// `WalShipper` and receivers can identify the source shard.
    /// Defaults to `ShardId::SINGLE` for single-node deployments.
    pub shard_id: ShardId,
    /// Region identity for this writer. Written into every batch header.
    /// Defaults to `RegionId::SINGLE` for single-node deployments.
    pub region_id: RegionId,
}

/// The group commit writer loop.
///
/// Runs on a dedicated thread. Receives events via crossbeam channel,
/// accumulates them into batches, writes batches to the WAL segment,
/// and fsyncs once per batch. Callers are notified of their sequence
/// numbers via per-event reply channels.
///
/// # Batch formation
///
/// 1. Block until the first event arrives.
/// 2. Drain additional events from the channel up to `batch_size` or
///    until `batch_timeout` elapses (whichever comes first).
/// 3. Deduplicate events, encode the batch, write to segment, fsync.
/// 4. Send sequence numbers back to all waiting callers.
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure during batch writes or fsync.
/// Returns `WalError::Corruption` if batch encoding fails (should not happen
/// under normal operation).
///
/// # Panics
///
/// Panics if the system clock is before the Unix epoch (same as `Timestamp::now()`).
// The function exceeds 100 lines due to the shutdown-drain path (B-3 fix).
// Extracting a helper would require restructuring the module, which is outside
// the scope of these targeted fixes.
#[allow(clippy::too_many_lines)]
pub fn run_writer(
    rx: &Receiver<WalCommand>,
    config: &WriterConfig,
    mut segment: SegmentWriter,
    start_seq: u64,
    mut dedup: DedupWindow,
) -> Result<(), WalError> {
    let mut next_seq = start_seq;
    let mut batch: Vec<(
        EventRecord,
        crossbeam::channel::Sender<Result<u64, WalError>>,
    )> = Vec::with_capacity(config.batch_size);
    let mut shutdown_requested = false;

    // Open the session journal if a path was provided (persistent mode).
    let mut session_journal: Option<SessionJournal> = config
        .session_journal_path
        .as_ref()
        .and_then(|p| match SessionJournal::open(p) {
            Ok(j) => Some(j),
            Err(e) => {
                tracing::error!(error = %e, "failed to open session journal; session WAL writes will be skipped");
                None
            }
        });

    loop {
        // Block until the first event arrives (or shutdown/disconnect)
        match rx.recv() {
            Ok(WalCommand::Append { event, reply }) => {
                batch.push((event, reply));
            }
            Ok(WalCommand::TruncateBefore { before_seq, reply }) => {
                let result = segment::delete_segments_before(&config.dir, before_seq);
                let _ = reply.send(result.map(|_| ()));
                continue;
            }
            Ok(
                cmd @ (WalCommand::SessionStart { .. }
                | WalCommand::SessionSignal { .. }
                | WalCommand::SessionClose { .. }),
            ) => {
                handle_session_command(cmd, &mut session_journal);
                continue;
            }
            Ok(WalCommand::Shutdown) | Err(_) => {
                break;
            }
        }

        // Drain up to batch_size with deadline
        let deadline = Instant::now() + config.batch_timeout;
        while batch.len() < config.batch_size {
            match rx.recv_deadline(deadline) {
                Ok(WalCommand::Append { event, reply }) => {
                    batch.push((event, reply));
                }
                Ok(WalCommand::TruncateBefore { before_seq, reply }) => {
                    let result = segment::delete_segments_before(&config.dir, before_seq);
                    let _ = reply.send(result.map(|_| ()));
                    // Continue draining the batch; truncation is a side-effect,
                    // not a batch-terminating event.
                }
                Ok(
                    cmd @ (WalCommand::SessionStart { .. }
                    | WalCommand::SessionSignal { .. }
                    | WalCommand::SessionClose { .. }),
                ) => {
                    handle_session_command(cmd, &mut session_journal);
                    // Session commands bypass the batch; continue draining.
                }
                Ok(WalCommand::Shutdown)
                | Err(crossbeam::channel::RecvTimeoutError::Disconnected) => {
                    shutdown_requested = true;
                    break;
                }
                Err(crossbeam::channel::RecvTimeoutError::Timeout) => break,
            }
        }

        // Deduplicate and separate into kept events and duplicate replies
        let mut kept_events: Vec<EventRecord> = Vec::with_capacity(batch.len());
        let mut kept_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> =
            Vec::with_capacity(batch.len());
        let mut dup_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> = Vec::new();

        // drain(..) is intentional: we reuse batch's heap allocation across loop iterations.
        #[allow(clippy::iter_with_drain)]
        for (event, reply) in batch.drain(..) {
            if dedup.is_duplicate(&event) {
                dup_replies.push(reply);
            } else {
                kept_events.push(event);
                kept_replies.push(reply);
            }
        }

        // Notify duplicate senders with seq=0 (sentinel for dedup).
        for reply in dup_replies {
            let _ = reply.send(Ok(0));
        }

        // Write the batch if there are any non-duplicate events
        if !kept_events.is_empty() {
            let batch_seq = next_seq;
            let batch_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 batch_ts_u64 = batch_ts as u64;

            // Wrap the write path in a closure so we can notify callers of
            // the specific error before propagating it. Without this, an
            // early `?` return would drop pending reply channels, leaving
            // callers blocked forever (or receiving a generic Closed error
            // instead of the real I/O error).
            let write_result = (|| -> Result<u64, WalError> {
                let encoded = format::encode_batch_with_shard(
                    &kept_events,
                    batch_seq,
                    batch_ts_u64,
                    config.shard_id,
                    config.region_id,
                )?;

                if segment.needs_rotation() {
                    segment.rotate(batch_seq)?;
                }

                segment.write_batch_bytes(&encoded)?;
                segment.sync()?;
                Ok(batch_seq)
            })();

            match write_result {
                Ok(_) => {
                    let event_count = kept_events.len() as u64;
                    segment.set_last_seq(batch_seq + event_count - 1);

                    tracing::debug!(seq = batch_seq, events = event_count, "wal: batch appended");

                    for (i, reply) in kept_replies.into_iter().enumerate() {
                        let _ = reply.send(Ok(batch_seq + i as u64));
                    }

                    next_seq = batch_seq + event_count;
                }
                Err(ref err) => {
                    // Notify all waiting callers with the actual error before
                    // propagating. We cannot clone WalError, so we send a
                    // synthetic I/O error with the same description.
                    let err_msg = err.to_string();
                    for reply in kept_replies {
                        let _ =
                            reply.send(Err(WalError::Io(std::io::Error::other(err_msg.clone()))));
                    }
                    // write_result is known to be Err here; the Ok branch is
                    // handled above, so this else-branch is unreachable.
                    return Err(write_result
                        .expect_err("write_result is Err in this branch; Ok is handled above"));
                }
            }
        }

        if shutdown_requested {
            break;
        }
    }

    // Drain any remaining commands that arrived before senders observed
    // the shutdown. This ensures in-flight append() calls are not silently
    // dropped, which would cause callers to block forever or receive
    // WalError::Closed instead of a real sequence number.
    let mut final_batch: Vec<(
        EventRecord,
        crossbeam::channel::Sender<Result<u64, WalError>>,
    )> = Vec::new();
    loop {
        match rx.try_recv() {
            Ok(WalCommand::Append { event, reply }) => {
                final_batch.push((event, reply));
            }
            Ok(WalCommand::TruncateBefore { before_seq, reply }) => {
                let result = segment::delete_segments_before(&config.dir, before_seq);
                let _ = reply.send(result.map(|_| ()));
            }
            Ok(WalCommand::Shutdown) => {
                // Ignore duplicate shutdown commands
            }
            Ok(
                cmd @ (WalCommand::SessionStart { .. }
                | WalCommand::SessionSignal { .. }
                | WalCommand::SessionClose { .. }),
            ) => {
                handle_session_command(cmd, &mut session_journal);
            }
            Err(
                crossbeam::channel::TryRecvError::Empty
                | crossbeam::channel::TryRecvError::Disconnected,
            ) => break,
        }
    }

    // Flush the final drain batch if non-empty
    if !final_batch.is_empty() {
        let mut kept_events: Vec<EventRecord> = Vec::with_capacity(final_batch.len());
        let mut kept_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> =
            Vec::with_capacity(final_batch.len());
        let mut dup_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> = Vec::new();

        for (event, reply) in final_batch {
            if dedup.is_duplicate(&event) {
                dup_replies.push(reply);
            } else {
                kept_events.push(event);
                kept_replies.push(reply);
            }
        }

        for reply in dup_replies {
            let _ = reply.send(Ok(0));
        }

        if !kept_events.is_empty() {
            let batch_seq = next_seq;
            let batch_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 batch_ts_u64 = batch_ts as u64;

            let encoded = format::encode_batch_with_shard(
                &kept_events,
                batch_seq,
                batch_ts_u64,
                config.shard_id,
                config.region_id,
            )?;

            if segment.needs_rotation() {
                segment.rotate(batch_seq)?;
            }

            segment.write_batch_bytes(&encoded)?;
            segment.sync()?;

            let event_count = kept_events.len() as u64;
            segment.set_last_seq(batch_seq + event_count - 1);

            for (i, reply) in kept_replies.into_iter().enumerate() {
                let _ = reply.send(Ok(batch_seq + i as u64));
            }
        }
    }

    // Final sync before exit
    segment.sync()?;
    Ok(())
}

/// Write a session lifecycle command to the session journal.
///
/// This function is called from the writer thread. Session commands bypass the
/// signal batch system entirely. Errors are logged and swallowed -- session WAL
/// writes are best-effort; the in-memory session state is the source of truth.
fn handle_session_command(cmd: WalCommand, journal: &mut Option<SessionJournal>) {
    let Some(journal) = journal.as_mut() else {
        // No session journal open (should not happen in persistent mode, but
        // log defensively).
        return;
    };

    let event = match cmd {
        WalCommand::SessionStart {
            session_id,
            user_id,
            started_at_ns,
            agent_id,
            policy_name,
        } => SessionWalEvent::Start {
            session_id,
            user_id,
            started_at_ns,
            agent_id,
            policy_name,
        },
        WalCommand::SessionSignal {
            session_id,
            entity_id,
            weight,
            ts_ns,
            signal_name,
            annotation,
            session_seqno,
            idempotency_key,
        } => SessionWalEvent::Signal {
            session_id,
            entity_id,
            weight,
            ts_ns,
            signal_name,
            annotation,
            session_seqno: session_seqno.map(SessionSeqNo),
            idempotency_key,
        },
        WalCommand::SessionClose { session_id } => SessionWalEvent::Close { session_id },
        // Other commands are not handled here.
        _ => return,
    };

    if let Err(e) = journal.append(&event) {
        tracing::warn!(error = %e, "session journal write failed");
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::similar_names)]
#[path = "writer_tests.rs"]
mod tests;
