//! RLHF training data export and cross-session aggregation types.

use std::collections::{BinaryHeap, HashMap, HashSet};

use crate::schema::{SchemaError, SignalTypeDef, TidalError};

// ── ExportFormat ──────────────────────────────────────────────────────────────

/// Output format for exported signals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
    /// One JSON object per line, no enclosing array.
    JsonLines,
}

// ── ExportRequest ─────────────────────────────────────────────────────────────

/// Request parameters for signal export.
#[derive(Debug, Clone)]
pub struct ExportRequest {
    /// Filter to signals for a specific user. `None` means all users.
    pub user_id: Option<u64>,
    /// Signal type names to include. Empty means all signal types.
    pub signal_types: Vec<String>,
    /// Start of the time range (inclusive). Nanosecond timestamp.
    pub since: Option<u64>,
    /// End of the time range (exclusive). Nanosecond timestamp.
    pub until: Option<u64>,
    /// Output format.
    pub format: ExportFormat,
    /// Maximum number of events to return. `None` means no limit.
    pub limit: Option<usize>,
}

impl ExportRequest {
    /// Maximum number of signals that can be exported in a single request.
    pub const MAX_EXPORT_LIMIT: usize = 500_000;
    /// Default limit applied when `limit` is `None`.
    pub const DEFAULT_LIMIT: usize = 100_000;
}

impl ExportRequest {
    /// Create a request for all signals in a time range.
    #[must_use]
    pub const fn time_range(since: u64, until: u64) -> Self {
        Self {
            user_id: None,
            signal_types: Vec::new(),
            since: Some(since),
            until: Some(until),
            format: ExportFormat::JsonLines,
            limit: None,
        }
    }

    /// Create a request for specific signal types in a time range.
    #[must_use]
    pub const fn signals_in_range(signal_types: Vec<String>, since: u64, until: u64) -> Self {
        Self {
            user_id: None,
            signal_types,
            since: Some(since),
            until: Some(until),
            format: ExportFormat::JsonLines,
            limit: None,
        }
    }
}

// ── ExportedSignal ────────────────────────────────────────────────────────────

/// A single exported signal event.
#[derive(Debug, Clone, PartialEq)]
pub struct ExportedSignal {
    /// The entity (content item) that received the signal.
    pub entity_id: u64,
    /// The signal type name (e.g., "view", "like", "skip").
    pub signal_type: String,
    /// The signal weight at write time.
    pub weight: f32,
    /// Nanosecond timestamp when the signal was written.
    pub timestamp_ns: u64,
    /// The user who generated the signal, if known (from session journal).
    pub user_id: Option<u64>,
    /// The session in which the signal was written, if known.
    pub session_id: Option<u64>,
    /// Optional annotation attached to the signal (e.g., RLHF label).
    pub annotation: Option<String>,
}

/// Wire form of [`ExportedSignal`] used only for JSON-Lines serialization.
///
/// `serde_json` owns all string escaping here, so any control character
/// (newline, tab, NUL), Unicode codepoint, quote, or backslash that appears in
/// `signal_type` or `annotation` is escaped per RFC 8259. Hand-rolled escaping
/// (which only covered `\\` and `"`) would emit a literal newline mid-record
/// and split one logical record across two physical lines, corrupting the
/// JSON-Lines stream that downstream RLHF tooling parses line-by-line.
///
/// Optional fields are `skip_serializing_if = "Option::is_none"` so anonymous
/// batch signals stay compact (no `user_id`/`session_id`/`annotation` keys),
/// preserving the prior output shape exactly for the common case.
#[derive(serde::Serialize)]
struct ExportedSignalWire<'a> {
    entity_id: u64,
    signal_type: &'a str,
    weight: f32,
    timestamp_ns: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    user_id: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    session_id: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    annotation: Option<&'a str>,
}

impl ExportedSignal {
    /// Render as a JSON line (no trailing newline).
    ///
    /// Optional fields (`user_id`, `session_id`, `annotation`) are included
    /// only when `Some`, keeping the format compact for anonymous batch signals.
    ///
    /// All string fields are escaped by `serde_json`, so control characters
    /// (newline/tab/NUL), Unicode, quotes, and backslashes in an annotation
    /// cannot break the one-record-per-physical-line JSON-Lines invariant.
    #[must_use]
    pub fn to_json_line(&self) -> String {
        let wire = ExportedSignalWire {
            entity_id: self.entity_id,
            signal_type: &self.signal_type,
            weight: self.weight,
            timestamp_ns: self.timestamp_ns,
            user_id: self.user_id,
            session_id: self.session_id,
            annotation: self.annotation.as_deref(),
        };
        // serde_json only fails to serialize on map keys that are not strings
        // or on custom Serialize impls that error; this flat struct of scalars
        // and strings cannot fail, so a serialization error would be a logic
        // bug. Fall back to an empty JSON object rather than panic on the
        // export hot path (export must never abort a batch over one record).
        serde_json::to_string(&wire).unwrap_or_else(|_| "{}".to_string())
    }
}

// ── UserSessionSummary ────────────────────────────────────────────────────────

/// Aggregate summary of a user's session history.
///
/// Produced by `TidalDb::user_session_summary()` (Task 09). The struct is
/// defined here so the export module owns all cross-session analysis types.
#[derive(Debug, Clone)]
pub struct UserSessionSummary {
    /// Number of closed sessions for this user in the time range.
    pub sessions_count: u64,
    /// Total signal writes across all matching sessions.
    pub total_signals: u64,
    /// Total policy rejections across all matching sessions.
    pub total_rejections: u64,
    /// Top signal types by frequency, sorted descending. Limited to top 10.
    pub top_signal_types: Vec<(String, u64)>,
    /// The user ID this summary is for.
    pub user_id: u64,
    /// The `since_ns` filter applied.
    pub since_ns: u64,
    /// Nanosecond timestamp of the earliest matching session start.
    pub earliest_session_ns: Option<u64>,
    /// Nanosecond timestamp of the latest matching session close.
    pub latest_session_ns: Option<u64>,
}

// ── TidalDb::export_signals ───────────────────────────────────────────────────

impl super::TidalDb {
    /// Export signal events from the WAL for offline analysis or RLHF training.
    ///
    /// Reads WAL segment files directly (all segments, not just post-checkpoint).
    /// Does not interfere with the live write path.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if WAL segments cannot be read.
    /// - `TidalError::Schema(UnknownSignalType)` if a requested signal type
    ///   name is not defined in the schema.
    pub fn export_signals(&self, request: &ExportRequest) -> crate::Result<Vec<ExportedSignal>> {
        // Validate export limit.
        if let Some(limit) = request.limit
            && limit > ExportRequest::MAX_EXPORT_LIMIT
        {
            return Err(TidalError::invalid_input(format!(
                "export limit {limit} exceeds maximum {}",
                ExportRequest::MAX_EXPORT_LIMIT
            )));
        }

        // Validate the time window. An inverted range (`since > until`) is always
        // empty given the inclusive-since / exclusive-until filter, so silently
        // returning an empty Vec would mask a caller mistake (e.g. swapped
        // arguments). Surface it as a typed error instead.
        if let (Some(since), Some(until)) = (request.since, request.until)
            && since > until
        {
            return Err(TidalError::invalid_input(format!(
                "export time range is inverted: since ({since}) must be <= until ({until})"
            )));
        }

        // Apply default limit when none is specified.
        let effective_limit = request.limit.unwrap_or(ExportRequest::DEFAULT_LIMIT);

        // Build signal type name lookup: ID -> name (sorted alphabetically,
        // matching the ID assignment order in SignalLedger::new).
        //
        // WAL records store `signal_type` as a `u8`, so the schema can define at
        // most 256 signal types for the IDs to round-trip. The previous code
        // truncated index >255 to a `u8` behind a `debug_assert`, which in
        // release builds silently aliased signal type #256 onto #0 and mislabeled
        // every exported record. We surface this as a typed error instead of
        // corrupting the export.
        let type_names: HashMap<u8, String> = match self.schema_def.as_ref() {
            None => HashMap::new(),
            Some(schema) => {
                let mut signal_list: Vec<&SignalTypeDef> = schema.signals().collect();
                signal_list.sort_by_key(|s| s.name());
                let mut map = HashMap::with_capacity(signal_list.len());
                for (i, def) in signal_list.iter().enumerate() {
                    let id = u8::try_from(i).map_err(|_| {
                        TidalError::invalid_input(format!(
                            "schema defines {} signal types; export supports at most 256 \
                             because WAL signal-type IDs are 8-bit",
                            signal_list.len()
                        ))
                    })?;
                    map.insert(id, def.name().to_owned());
                }
                map
            }
        };

        // Build type filter set: if signal_types non-empty, resolve to u8 IDs.
        let type_filter: HashSet<u8> = if request.signal_types.is_empty() {
            HashSet::new()
        } else {
            let mut filter = HashSet::with_capacity(request.signal_types.len());
            for name in &request.signal_types {
                let id = type_names
                    .iter()
                    .find(|(_, n)| *n == name)
                    .map(|(id, _)| *id)
                    .ok_or_else(|| {
                        TidalError::Schema(SchemaError::UnknownSignalType(name.clone()))
                    })?;
                filter.insert(id);
            }
            filter
        };

        // Find WAL directory — through the single resolver so an explicit
        // wal_dir override is read from the same place the writer wrote it.
        let Some(wal_dir) = self.config.resolved_wal_dir() else {
            // Ephemeral mode: no WAL on disk.
            return Ok(Vec::new());
        };

        if !wal_dir.exists() {
            return Ok(Vec::new());
        }

        // Batch WAL events: skip entirely when user_id filter is set because
        // batch WAL records have no user context — including them would pollute
        // user-filtered results with anonymous events.
        //
        // Bounded scan (was: load every WAL event into one unbounded Vec, then
        // truncate). `read_all_events` materialized the *entire* pre-compaction
        // WAL — tens of millions of events under write load — regardless of the
        // requested limit, so a `limit: Some(10)` export could OOM. We instead
        // stream segments and retain at most `effective_limit` events in memory
        // via a bounded heap keyed by timestamp (see `scan_batch_wal_bounded`).
        let mut results = Vec::new();
        if request.user_id.is_none() {
            let batch_signals = scan_batch_wal_bounded(
                &wal_dir,
                request,
                &type_filter,
                &type_names,
                effective_limit,
            )
            .map_err(|e| TidalError::internal("export_signals", e.to_string()))?;
            results.extend(batch_signals);
        }

        // Session journal events: always read (they carry user context). Bounded
        // to `effective_limit` so the never-compacted journal cannot OOM a
        // small-limit export (the merge below re-sorts and re-truncates).
        let session_signals = read_session_journal_signals(&wal_dir, request, effective_limit);
        results.extend(session_signals);

        // Sort merged results by timestamp ascending for deterministic output.
        results.sort_by_key(|s| s.timestamp_ns);

        // Apply limit across the merged, sorted result.
        results.truncate(effective_limit);

        Ok(results)
    }

    /// Export signal events serialized to the request's wire [`ExportFormat`].
    ///
    /// This is the format-aware entrypoint over [`export_signals`](Self::export_signals):
    /// it runs the same bounded scan and then renders each [`ExportedSignal`] to
    /// the requested wire format. For [`ExportFormat::JsonLines`] every record is
    /// one [`ExportedSignal::to_json_line`] joined by `\n` (no trailing newline),
    /// the exact shape downstream RLHF tooling parses line-by-line.
    ///
    /// Use [`export_signals`](Self::export_signals) when you want the structs to
    /// post-process in-process; use this when you want the bytes to write to a
    /// file or stream.
    ///
    /// # Errors
    ///
    /// Propagates every error from [`export_signals`](Self::export_signals)
    /// (invalid limit, inverted time range, unknown signal type, unreadable WAL).
    pub fn export_signals_jsonl(&self, request: &ExportRequest) -> crate::Result<String> {
        let signals = self.export_signals(request)?;
        Ok(render_signals(&signals, request.format))
    }
}

/// Render exported signals to the given wire [`ExportFormat`].
///
/// Kept as a free function (not a method on `ExportRequest`) so the rendering is
/// driven solely by the chosen format and the records, with no hidden coupling
/// to other request fields. A `match` on `format` means adding a new variant is
/// a compile error here until it is handled — the format is wired, not dead.
fn render_signals(signals: &[ExportedSignal], format: ExportFormat) -> String {
    match format {
        ExportFormat::JsonLines => {
            let mut out = String::new();
            for (i, sig) in signals.iter().enumerate() {
                if i > 0 {
                    out.push('\n');
                }
                out.push_str(&sig.to_json_line());
            }
            out
        }
    }
}

// ── BoundedEarliest ───────────────────────────────────────────────────────────

/// Heap element ordered ONLY by its `(timestamp, ordinal)` key.
///
/// `ExportedSignal` is not `Ord` (it carries floats), so it cannot go in a
/// `BinaryHeap` directly. This wrapper derives ordering from the key alone: a
/// plain max-heap of `HeapEntry` yields the LARGEST timestamp at `peek`/`pop` —
/// exactly the element to evict when the heap exceeds the limit, leaving the
/// smallest-timestamp (earliest) survivors. The `ordinal` breaks timestamp ties
/// deterministically so eviction is stable across runs.
struct HeapEntry {
    key: (u64, u64),
    signal: ExportedSignal,
}
impl PartialEq for HeapEntry {
    fn eq(&self, other: &Self) -> bool {
        self.key == other.key
    }
}
impl Eq for HeapEntry {}
impl PartialOrd for HeapEntry {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for HeapEntry {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.key.cmp(&other.key)
    }
}

/// A bounded retainer that keeps at most `limit` `ExportedSignal`s with the
/// SMALLEST `(timestamp_ns, ordinal)` key, so peak memory is O(limit) regardless
/// of how many candidates are offered.
///
/// Shared by the batch-WAL and session-journal scans so neither can reintroduce
/// the unbounded-`Vec` OOM the bounded scan exists to prevent. The session
/// journal in particular is a single never-compacted append-only file, so a
/// small-`limit` export against a large journal MUST NOT materialize every event.
struct BoundedEarliest {
    heap: BinaryHeap<HeapEntry>,
    limit: usize,
    ordinal: u64,
}

impl BoundedEarliest {
    const fn new(limit: usize) -> Self {
        Self {
            heap: BinaryHeap::new(),
            limit,
            ordinal: 0,
        }
    }

    /// Offer one candidate. Retains it only if it is among the `limit` earliest
    /// seen; otherwise (or once the heap is full and this is newer than the
    /// current max) it is dropped immediately, never accumulated.
    fn offer(&mut self, timestamp_ns: u64, signal: ExportedSignal) {
        if self.limit == 0 {
            return;
        }
        self.heap.push(HeapEntry {
            key: (timestamp_ns, self.ordinal),
            signal,
        });
        self.ordinal += 1;
        // Cap the working set: evict the current largest timestamp so the heap
        // never exceeds `limit` entries.
        if self.heap.len() > self.limit {
            self.heap.pop();
        }
    }

    fn into_vec(self) -> Vec<ExportedSignal> {
        self.heap.into_iter().map(|entry| entry.signal).collect()
    }
}

/// Whether `ts_ns` falls inside the request's time window.
///
/// The window is **since-inclusive, until-exclusive** (`[since, until)`). Both
/// bounds are optional; an absent bound is open on that side. Shared by both
/// scan paths (batch WAL and session journal) so the inclusivity convention
/// lives in exactly one place and cannot drift between them.
const fn in_time_window(ts_ns: u64, since: Option<u64>, until: Option<u64>) -> bool {
    // Written without `if let ... &&` let-chains so it stays a `const fn` on
    // stable (let-chains in const context are not yet stabilized).
    let after_since = match since {
        Some(since) => ts_ns >= since,
        None => true,
    };
    let before_until = match until {
        Some(until) => ts_ns < until,
        None => true,
    };
    after_since && before_until
}

// ── scan_batch_wal_bounded ────────────────────────────────────────────────────

/// Stream the batch WAL segments and return at most `limit` matching events,
/// holding no more than `limit` events in memory at any point.
///
/// # Why this is not `read_all_events`
///
/// [`read_all_events`](crate::wal::reader::read_all_events) decodes EVERY event
/// in EVERY segment into one `Vec` before any filter or limit applies. Between
/// checkpoints the WAL can hold tens of millions of events (online compaction
/// only deletes segments fully covered by a checkpoint), so a small-limit export
/// on a large pre-compaction WAL allocated the whole tail and could OOM. This
/// scanner pushes the time-range + signal-type filters and the `limit` *down*
/// into the segment-by-segment scan and retains only the surviving window.
///
/// # Which events survive
///
/// To preserve the prior output (the earliest-N events in the range — the merge
/// step then re-sorts ascending by timestamp and truncates), this keeps the
/// `limit` events with the SMALLEST `timestamp_ns` seen across all segments. A
/// max-heap keyed by timestamp evicts the current largest once the heap exceeds
/// `limit`, so the working set is capped at `limit` regardless of WAL size. With
/// `limit == 0` no events are retained.
///
/// Segments are scanned read-only ([`scan_segment_readonly`] never truncates a
/// torn tail), so this is safe to run concurrently with the live writer thread.
///
/// # Errors
///
/// Returns [`WalError`](crate::wal::error::WalError) if a segment file cannot be
/// listed or opened. A torn tail in the active segment is ignored, not an error.
fn scan_batch_wal_bounded(
    wal_dir: &std::path::Path,
    request: &ExportRequest,
    type_filter: &HashSet<u8>,
    type_names: &HashMap<u8, String>,
    limit: usize,
) -> Result<Vec<ExportedSignal>, crate::wal::error::WalError> {
    use crate::wal::{reader::scan_segment_readonly, segment::list_segments};

    let mut retained = BoundedEarliest::new(limit);

    // A zero limit cannot retain anything; skip the scan entirely.
    if limit == 0 {
        return Ok(Vec::new());
    }

    for (_seq, path) in &list_segments(wal_dir)? {
        for (_header, events) in scan_segment_readonly(path)? {
            for event in events {
                // Time range filter: since-inclusive, until-exclusive.
                if !in_time_window(event.timestamp_nanos, request.since, request.until) {
                    continue;
                }

                // Signal type filter: empty filter means all types pass.
                if !type_filter.is_empty() && !type_filter.contains(&event.signal_type) {
                    continue;
                }

                let signal_type_name = type_names
                    .get(&event.signal_type)
                    .cloned()
                    .unwrap_or_else(|| format!("unknown:{}", event.signal_type));

                retained.offer(
                    event.timestamp_nanos,
                    ExportedSignal {
                        entity_id: event.entity_id,
                        signal_type: signal_type_name,
                        weight: event.weight,
                        timestamp_ns: event.timestamp_nanos,
                        user_id: None,
                        session_id: None,
                        annotation: None,
                    },
                );
            }
        }
    }

    Ok(retained.into_vec())
}

// ── read_session_journal_signals ──────────────────────────────────────────────

/// Largest session frame we will allocate while streaming. A corrupt length
/// prefix (a flipped byte) could otherwise claim a multi-gigabyte body and OOM
/// before the `read_exact` that follows would fail; anything larger than this
/// generous ceiling is treated as corruption and stops the scan, mirroring the
/// whole-buffer decoder's bounds check.
const MAX_SESSION_FRAME_BYTES: usize = 64 * 1024 * 1024;

/// Stream session journal signals from disk and apply the same filters as the
/// batch WAL path (time range, signal type name, user ID), retaining at most
/// `limit` matching events.
///
/// # Bounded memory (OOM fix)
///
/// The session journal is a single, never-compacted append-only file that grows
/// for the life of the database. The previous implementation `std::fs::read` the
/// WHOLE file, decoded EVERY event into a `Vec`, and collected ALL matches into
/// an unbounded `results` `Vec` — so a `limit: Some(10)` export against a
/// multi-GB journal allocated the entire file plus a fully-materialized signal
/// vector, defeating the very bound the batch-WAL path was built to enforce
/// (a CRITICAL OOM). This streams one length-prefixed frame at a time and
/// retains only the `limit` earliest-timestamp matches via [`BoundedEarliest`],
/// so peak memory is O(largest frame + limit) regardless of journal size.
///
/// The journal is strictly append-ordered, so a session's `Start` is always read
/// before its `Signal`s; a single streaming pass therefore resolves every
/// signal's user without a second pass over the file.
///
/// Returns an empty vec if the session journal does not exist or cannot be read
/// (non-fatal — the batch WAL results are still valid).
// The export wire intentionally narrows the f64 session weight to f32 for the
// RLHF/training output format; the durable journal keeps full precision.
#[allow(clippy::cast_possible_truncation)]
fn read_session_journal_signals(
    wal_dir: &std::path::Path,
    request: &ExportRequest,
    limit: usize,
) -> Vec<ExportedSignal> {
    use std::io::Read;

    use crate::wal::{
        format::{SessionWalEvent, decode_session_events_with_diagnostics},
        session_journal::SESSION_JOURNAL_FILENAME,
    };

    let journal_path = wal_dir.join(SESSION_JOURNAL_FILENAME);
    let Ok(file) = std::fs::File::open(&journal_path) else {
        return Vec::new(); // missing or unreadable -> non-fatal
    };
    let mut reader = std::io::BufReader::new(file);

    // Signal type name filter set for string-based matching.
    let type_filter_set: HashSet<&str> = request.signal_types.iter().map(String::as_str).collect();

    // Incremental session_id -> user_id map (bounded by session count, not event
    // count) and the bounded earliest-N retainer.
    let mut session_user: HashMap<u64, u64> = HashMap::new();
    let mut retained = BoundedEarliest::new(limit);

    let mut len_buf = [0u8; 4];
    loop {
        // Read the 4-byte length prefix; a short read is a clean EOF / torn tail.
        if reader.read_exact(&mut len_buf).is_err() {
            break;
        }
        let body_len = u32::from_le_bytes(len_buf) as usize;
        // Guard a corrupt/huge length BEFORE allocating, so a flipped prefix byte
        // cannot trigger a giant allocation.
        if body_len > MAX_SESSION_FRAME_BYTES {
            break;
        }
        // Reconstruct the full frame ([len][body]) for the existing per-frame
        // decoder, which verifies the checksum.
        let mut frame = vec![0u8; SESSION_LEN_PREFIX_BYTES + body_len];
        frame[..SESSION_LEN_PREFIX_BYTES].copy_from_slice(&len_buf);
        if reader
            .read_exact(&mut frame[SESSION_LEN_PREFIX_BYTES..])
            .is_err()
        {
            break; // torn tail: incomplete final frame
        }
        let outcome = decode_session_events_with_diagnostics(&frame);
        // Distinguish a *corrupt* frame from a forward-compat *skip*. A v2 frame
        // carrying an unknown-but-checksum-valid record type decodes to zero
        // events with corruption_detected == false (decode_typed_record returns
        // Skipped); the whole-buffer decoder skips such a frame and continues, so
        // this streaming reader must too. Breaking on `events.is_empty()` here
        // silently dropped every later session the instant a newer writer added a
        // 4th record type. Stop only on genuine corruption.
        if outcome.corruption_detected {
            break;
        }
        for event in outcome.events {
            match event {
                SessionWalEvent::Start {
                    session_id,
                    user_id,
                    ..
                } => {
                    session_user.insert(session_id, user_id);
                }
                SessionWalEvent::Signal {
                    session_id,
                    entity_id,
                    weight,
                    ts_ns,
                    signal_name,
                    annotation,
                    ..
                } => {
                    // User filter: look up user_id from session start mapping.
                    let mapped_user_id = session_user.get(&session_id).copied();
                    if let Some(filter_uid) = request.user_id
                        && mapped_user_id != Some(filter_uid)
                    {
                        continue;
                    }
                    // Time range filter: since-inclusive, until-exclusive.
                    if !in_time_window(ts_ns, request.since, request.until) {
                        continue;
                    }
                    // Signal type filter: if non-empty, check signal_name.
                    if !type_filter_set.is_empty()
                        && !type_filter_set.contains(signal_name.as_str())
                    {
                        continue;
                    }
                    retained.offer(
                        ts_ns,
                        ExportedSignal {
                            entity_id,
                            // The RLHF export wire keeps weight as f32 (a
                            // display/training format, not the durable score); the
                            // session journal stores it as f64, narrow it here.
                            weight: weight as f32,
                            signal_type: signal_name,
                            timestamp_ns: ts_ns,
                            user_id: mapped_user_id,
                            session_id: Some(session_id),
                            annotation,
                        },
                    );
                }
                SessionWalEvent::Close { .. } => {}
            }
        }
    }

    retained.into_vec()
}

/// Length of the session-frame length prefix (`u32` little-endian).
const SESSION_LEN_PREFIX_BYTES: usize = 4;

// ── TidalDb::user_session_summary ────────────────────────────────────────────

impl super::TidalDb {
    /// Compute an aggregate summary of a user's closed session history.
    ///
    /// Scans `closed_sessions` in memory for sessions belonging to `user_id`
    /// that started at or after `since_ns`.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::NotFound` if no closed sessions exist for the
    /// specified user in the given time range.
    pub fn user_session_summary(
        &self,
        user_id: u64,
        since_ns: u64,
    ) -> crate::Result<UserSessionSummary> {
        use crate::schema::{EntityId, EntityKind};

        let mut sessions_count: u64 = 0;
        let mut total_signals: u64 = 0;
        let mut total_rejections: u64 = 0;
        let mut signal_freq: std::collections::HashMap<String, u64> =
            std::collections::HashMap::new();
        let mut earliest_ns: Option<u64> = None;
        let mut latest_ns: Option<u64> = None;

        for entry in &self.closed_sessions {
            let snapshot = entry.value();
            if snapshot.user_id != user_id {
                continue;
            }
            if snapshot.started_at_ns < since_ns {
                continue;
            }

            sessions_count += 1;
            total_signals += snapshot.signals_written;
            total_rejections += snapshot.signals_rejected;

            // Accumulate signal type frequencies from the per-signal count.
            for (signal_name, signal_entry) in &snapshot.signals {
                *signal_freq.entry(signal_name.clone()).or_insert(0) += signal_entry.count;
            }

            // Track time range.
            earliest_ns = Some(earliest_ns.map_or(snapshot.started_at_ns, |e: u64| {
                e.min(snapshot.started_at_ns)
            }));
            latest_ns = Some(
                latest_ns.map_or(snapshot.closed_at_ns, |l: u64| l.max(snapshot.closed_at_ns)),
            );
        }

        if sessions_count == 0 {
            return Err(TidalError::NotFound {
                kind: EntityKind::User,
                id: EntityId::new(user_id),
            });
        }

        // Top signal types sorted descending by count, limited to 10.
        let mut top_signal_types: Vec<(String, u64)> = signal_freq.into_iter().collect();
        top_signal_types.sort_by(|a, b| b.1.cmp(&a.1));
        top_signal_types.truncate(10);

        Ok(UserSessionSummary {
            sessions_count,
            total_signals,
            total_rejections,
            top_signal_types,
            user_id,
            since_ns,
            earliest_session_ns: earliest_ns,
            latest_session_ns: latest_ns,
        })
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod session_journal_forward_compat_tests {
    use super::*;
    use crate::wal::{
        format::{SESSION_RECORD_VERSION_V2, SessionWalEvent, encode_session_event},
        session_journal::SESSION_JOURNAL_FILENAME,
    };

    /// W6: a v2 frame carrying a forward-compatible *unknown* record type decodes
    /// to zero events with `corruption_detected == false`. The streaming export
    /// reader must SKIP it and keep reading later frames — not break at the first
    /// empty decode and silently drop every subsequent session's signals.
    ///
    /// Layout proof: a v2 frame is `[len:u32 LE][marker:0xFF][version=2][type]
    /// [payload][checksum:8]`. We synthesize the unknown frame by taking a real
    /// encoded event, flipping its type byte to a value no current reader knows,
    /// and recomputing the BLAKE3 checksum so the frame is checksum-VALID (the
    /// exact forward-compat case v2 framing was designed to tolerate). This avoids
    /// hardcoding the private marker byte — we reuse the encoder's own framing.
    fn unknown_but_checksum_valid_v2_frame() -> Vec<u8> {
        // Frame: [len(4)][marker(1)][version(1)][type(1)][payload..][checksum(8)].
        const TYPE_OFFSET: usize = 4 + 2; // after len + marker + version
        const CHECKSUM_LEN: usize = 8;
        // Start from a real encoded frame so the marker/version/framing are
        // produced by the canonical encoder, then mutate the type byte.
        let mut frame = encode_session_event(&SessionWalEvent::Close { session_id: 7 }).unwrap();
        assert_eq!(
            frame[TYPE_OFFSET - 1],
            SESSION_RECORD_VERSION_V2,
            "encoder must emit a v2 frame for this test to be valid"
        );
        // An unknown type: not START(0x01)/SIGNAL(0x02)/CLOSE(0x03).
        frame[TYPE_OFFSET] = 0x7E;
        // Recompute the checksum over the body (everything between the length
        // prefix and the trailing checksum), matching session_record_checksum.
        let body_end = frame.len() - CHECKSUM_LEN;
        let digest = blake3::hash(&frame[4..body_end]);
        let checksum = digest.as_bytes();
        frame[body_end..].copy_from_slice(&checksum[..CHECKSUM_LEN]);
        frame
    }

    #[test]
    fn export_skips_unknown_frame_and_keeps_trailing_signal() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let journal_path = tmp.path().join(SESSION_JOURNAL_FILENAME);

        // Build: Start (defines session->user), unknown-but-valid v2 frame,
        // then a real Signal whose timestamp lands in the export window.
        let start = encode_session_event(&SessionWalEvent::Start {
            session_id: 100,
            user_id: 42,
            started_at_ns: 1_000,
            agent_id: "agent".to_string(),
            policy_name: "policy".to_string(),
        })
        .unwrap();
        let unknown = unknown_but_checksum_valid_v2_frame();
        let signal = encode_session_event(&SessionWalEvent::Signal {
            session_id: 100,
            entity_id: 999,
            weight: 1.5,
            ts_ns: 5_000,
            signal_name: "view".to_string(),
            annotation: None,
            session_seqno: None,
            idempotency_key: None,
        })
        .unwrap();

        let mut bytes = Vec::new();
        bytes.extend_from_slice(&start);
        bytes.extend_from_slice(&unknown);
        bytes.extend_from_slice(&signal);
        std::fs::write(&journal_path, &bytes).expect("write journal");

        let request = ExportRequest::time_range(0, u64::MAX);
        let exported = read_session_journal_signals(tmp.path(), &request, 100);

        assert_eq!(
            exported.len(),
            1,
            "the trailing Signal must survive an intervening unknown frame; got {exported:?}"
        );
        let sig = &exported[0];
        assert_eq!(sig.entity_id, 999);
        assert_eq!(sig.signal_type, "view");
        assert_eq!(
            sig.user_id,
            Some(42),
            "session->user mapping must be applied"
        );
    }
}

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