// ── Session journal record types ────────────────────────────────────────────

/// Record type discriminant for session start events.
pub const SESSION_RECORD_START: u8 = 0x01;
/// Record type discriminant for session signal events.
pub const SESSION_RECORD_SIGNAL: u8 = 0x02;
/// Record type discriminant for session close events.
pub const SESSION_RECORD_CLOSE: u8 = 0x03;

/// Monotonic sequence number for session writes.
///
/// Incremented once per session write operation (preference signal,
/// annotation, search query). Used by the receiver to enforce idempotent
/// replay: a write with seqno <= hwm is a no-op.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct SessionSeqNo(pub u64);

impl SessionSeqNo {
    pub const ZERO: Self = Self(0);
    #[must_use]
    /// Saturates at `u64::MAX` (~1.8e19), which is unreachable in practice.
    pub const fn next(self) -> Self {
        Self(self.0.saturating_add(1))
    }
}

impl std::fmt::Display for SessionSeqNo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ssn:{}", self.0)
    }
}

/// A session event decoded from the session journal.
///
/// These events are stored in a separate append-only file (`sessions.log`)
/// alongside the signal WAL. They are used to restore active sessions on
/// crash recovery.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionWalEvent {
    /// A session was started.
    Start {
        session_id: u64,
        user_id: u64,
        started_at_ns: u64,
        agent_id: String,
        policy_name: String,
    },
    /// A signal was written within a session.
    Signal {
        session_id: u64,
        entity_id: u64,
        weight: f32,
        ts_ns: u64,
        signal_name: String,
        annotation: Option<String>,
        // m8p4 extensions (default None for legacy events)
        session_seqno: Option<SessionSeqNo>,
        idempotency_key: Option<u128>,
    },
    /// A session was closed.
    Close { session_id: u64 },
}

/// Encode a session event to bytes for the session journal.
///
/// Format: `[len: u32 LE][type: u8][payload bytes]`
///
/// **Start payload**: `[session_id: u64 LE][user_id: u64 LE][started_at_ns: u64 LE]`
///   `[agent_id_len: u16 LE][agent_id: bytes][policy_name_len: u16 LE][policy_name: bytes]`
///
/// **Signal payload**: `[session_id: u64 LE][entity_id: u64 LE][weight: f32 LE][ts_ns: u64 LE]`
///   `[signal_name_len: u16 LE][signal_name: bytes][has_annotation: u8]`
///   `[if has_annotation: annotation_len: u16 LE, annotation: bytes]`
///
/// **Close payload**: `[session_id: u64 LE]`
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
    // Encode the payload first, then prepend the length+type header.
    let mut payload = Vec::new();
    match event {
        SessionWalEvent::Start {
            session_id,
            user_id,
            started_at_ns,
            agent_id,
            policy_name,
        } => {
            payload.push(SESSION_RECORD_START);
            payload.extend_from_slice(&session_id.to_le_bytes());
            payload.extend_from_slice(&user_id.to_le_bytes());
            payload.extend_from_slice(&started_at_ns.to_le_bytes());
            payload.extend_from_slice(&(agent_id.len() as u16).to_le_bytes());
            payload.extend_from_slice(agent_id.as_bytes());
            payload.extend_from_slice(&(policy_name.len() as u16).to_le_bytes());
            payload.extend_from_slice(policy_name.as_bytes());
        }
        SessionWalEvent::Signal {
            session_id,
            entity_id,
            weight,
            ts_ns,
            signal_name,
            annotation,
            session_seqno,
            idempotency_key,
        } => {
            payload.push(SESSION_RECORD_SIGNAL);
            payload.extend_from_slice(&session_id.to_le_bytes());
            payload.extend_from_slice(&entity_id.to_le_bytes());
            payload.extend_from_slice(&weight.to_le_bytes());
            payload.extend_from_slice(&ts_ns.to_le_bytes());
            payload.extend_from_slice(&(signal_name.len() as u16).to_le_bytes());
            payload.extend_from_slice(signal_name.as_bytes());
            match annotation {
                Some(ann) => {
                    payload.push(1u8);
                    payload.extend_from_slice(&(ann.len() as u16).to_le_bytes());
                    payload.extend_from_slice(ann.as_bytes());
                }
                None => {
                    payload.push(0u8);
                }
            }
            // m8p4: session_seqno (optional u64)
            match session_seqno {
                Some(seqno) => {
                    payload.push(1u8);
                    payload.extend_from_slice(&seqno.0.to_le_bytes());
                }
                None => {
                    payload.push(0u8);
                }
            }
            // m8p4: idempotency_key (optional u128)
            match idempotency_key {
                Some(key) => {
                    payload.push(1u8);
                    payload.extend_from_slice(&key.to_le_bytes());
                }
                None => {
                    payload.push(0u8);
                }
            }
        }
        SessionWalEvent::Close { session_id } => {
            payload.push(SESSION_RECORD_CLOSE);
            payload.extend_from_slice(&session_id.to_le_bytes());
        }
    }

    let len = payload.len() as u32;
    let mut buf = Vec::with_capacity(4 + payload.len());
    buf.extend_from_slice(&len.to_le_bytes());
    buf.extend(payload);
    buf
}

/// Decode all session events from a session journal file's contents.
///
/// Stops at the first truncated or malformed record. This is the correct
/// behavior for crash recovery: a torn write at the end of the file is
/// simply ignored.
#[must_use]
pub fn decode_session_events(bytes: &[u8]) -> Vec<SessionWalEvent> {
    let mut events = Vec::new();
    let mut pos = 0;

    while pos + 4 <= bytes.len() {
        let record_len =
            u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
                as usize;
        pos += 4;

        if pos + record_len > bytes.len() || record_len == 0 {
            // Truncated or zero-length record -- stop.
            break;
        }

        let record_end = pos + record_len;
        let record_type = bytes[pos];
        pos += 1;

        match record_type {
            SESSION_RECORD_START => {
                if let Some(event) = decode_start_record(bytes, &mut pos, record_end) {
                    events.push(event);
                } else {
                    break;
                }
            }
            SESSION_RECORD_SIGNAL => {
                if let Some(event) = decode_signal_record(bytes, &mut pos, record_end) {
                    events.push(event);
                } else {
                    break;
                }
            }
            SESSION_RECORD_CLOSE => {
                if pos + 8 > record_end {
                    break;
                }
                let session_id = read_u64_le(bytes, &mut pos);
                events.push(SessionWalEvent::Close { session_id });
            }
            _ => {
                // Unknown record type -- skip handled by `pos = record_end` below.
            }
        }

        // Ensure pos is at the expected record boundary.
        pos = record_end;
    }

    events
}

/// Helper: read a little-endian u64 from `bytes` at `*pos`, advancing `*pos`.
fn read_u64_le(bytes: &[u8], pos: &mut usize) -> u64 {
    let v = u64::from_le_bytes([
        bytes[*pos],
        bytes[*pos + 1],
        bytes[*pos + 2],
        bytes[*pos + 3],
        bytes[*pos + 4],
        bytes[*pos + 5],
        bytes[*pos + 6],
        bytes[*pos + 7],
    ]);
    *pos += 8;
    v
}

/// Helper: read a little-endian u16 from `bytes` at `*pos`, advancing `*pos`.
fn read_u16_le(bytes: &[u8], pos: &mut usize) -> u16 {
    let v = u16::from_le_bytes([bytes[*pos], bytes[*pos + 1]]);
    *pos += 2;
    v
}

/// Decode a Start record from the payload region.
fn decode_start_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<SessionWalEvent> {
    if *pos + 24 > end {
        return None;
    }
    let session_id = read_u64_le(bytes, pos);
    let user_id = read_u64_le(bytes, pos);
    let started_at_ns = read_u64_le(bytes, pos);

    if *pos + 2 > end {
        return None;
    }
    let agent_len = read_u16_le(bytes, pos) as usize;
    if *pos + agent_len > end {
        return None;
    }
    let agent_id = String::from_utf8_lossy(&bytes[*pos..*pos + agent_len]).to_string();
    *pos += agent_len;

    if *pos + 2 > end {
        return None;
    }
    let policy_len = read_u16_le(bytes, pos) as usize;
    if *pos + policy_len > end {
        return None;
    }
    let policy_name = String::from_utf8_lossy(&bytes[*pos..*pos + policy_len]).to_string();
    *pos += policy_len;

    Some(SessionWalEvent::Start {
        session_id,
        user_id,
        started_at_ns,
        agent_id,
        policy_name,
    })
}

/// Decode a Signal record from the payload region.
fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<SessionWalEvent> {
    // session_id(8) + entity_id(8) + weight(4) + ts_ns(8) = 28
    if *pos + 28 > end {
        return None;
    }
    let session_id = read_u64_le(bytes, pos);
    let entity_id = read_u64_le(bytes, pos);
    let weight = f32::from_le_bytes([
        bytes[*pos],
        bytes[*pos + 1],
        bytes[*pos + 2],
        bytes[*pos + 3],
    ]);
    *pos += 4;
    let ts_ns = read_u64_le(bytes, pos);

    if *pos + 2 > end {
        return None;
    }
    let sig_len = read_u16_le(bytes, pos) as usize;
    if *pos + sig_len > end {
        return None;
    }
    let signal_name = String::from_utf8_lossy(&bytes[*pos..*pos + sig_len]).to_string();
    *pos += sig_len;

    if *pos + 1 > end {
        return None;
    }
    let has_annotation = bytes[*pos] != 0;
    *pos += 1;

    let annotation = if has_annotation {
        if *pos + 2 > end {
            return None;
        }
        let ann_len = read_u16_le(bytes, pos) as usize;
        if *pos + ann_len > end {
            return None;
        }
        let ann = String::from_utf8_lossy(&bytes[*pos..*pos + ann_len]).to_string();
        *pos += ann_len;
        Some(ann)
    } else {
        None
    };

    // m8p4: session_seqno (backward-compatible — absent in legacy records)
    let session_seqno = if *pos < end {
        let has_seqno = bytes[*pos];
        *pos += 1;
        if has_seqno != 0 && *pos + 8 <= end {
            let v = read_u64_le(bytes, pos);
            Some(SessionSeqNo(v))
        } else {
            None
        }
    } else {
        None
    };

    // m8p4: idempotency_key (backward-compatible — absent in legacy records)
    let idempotency_key = if *pos < end {
        let has_key = bytes[*pos];
        *pos += 1;
        if has_key != 0 && *pos + 16 <= end {
            let v = u128::from_le_bytes([
                bytes[*pos],
                bytes[*pos + 1],
                bytes[*pos + 2],
                bytes[*pos + 3],
                bytes[*pos + 4],
                bytes[*pos + 5],
                bytes[*pos + 6],
                bytes[*pos + 7],
                bytes[*pos + 8],
                bytes[*pos + 9],
                bytes[*pos + 10],
                bytes[*pos + 11],
                bytes[*pos + 12],
                bytes[*pos + 13],
                bytes[*pos + 14],
                bytes[*pos + 15],
            ]);
            *pos += 16;
            Some(v)
        } else {
            None
        }
    } else {
        None
    };

    Some(SessionWalEvent::Signal {
        session_id,
        entity_id,
        weight,
        ts_ns,
        signal_name,
        annotation,
        session_seqno,
        idempotency_key,
    })
}

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

    #[test]
    fn session_start_roundtrip() {
        let event = SessionWalEvent::Start {
            session_id: 42,
            user_id: 100,
            started_at_ns: 1_000_000_000,
            agent_id: "test-agent".to_string(),
            policy_name: "default_policy".to_string(),
        };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], event);
    }

    #[test]
    fn session_signal_roundtrip_with_annotation() {
        let event = SessionWalEvent::Signal {
            session_id: 7,
            entity_id: 999,
            weight: 1.5,
            ts_ns: 2_000_000_000,
            signal_name: "reward".to_string(),
            annotation: Some("jazz fusion".to_string()),
            session_seqno: None,
            idempotency_key: None,
        };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], event);
    }

    #[test]
    fn session_signal_roundtrip_without_annotation() {
        let event = SessionWalEvent::Signal {
            session_id: 7,
            entity_id: 999,
            weight: 1.5,
            ts_ns: 2_000_000_000,
            signal_name: "view".to_string(),
            annotation: None,
            session_seqno: None,
            idempotency_key: None,
        };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], event);
    }

    #[test]
    fn session_close_roundtrip() {
        let event = SessionWalEvent::Close { session_id: 42 };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], event);
    }

    #[test]
    fn session_multiple_events_roundtrip() {
        let events = vec![
            SessionWalEvent::Start {
                session_id: 1,
                user_id: 10,
                started_at_ns: 100,
                agent_id: "agent-a".to_string(),
                policy_name: "policy-1".to_string(),
            },
            SessionWalEvent::Signal {
                session_id: 1,
                entity_id: 42,
                weight: 1.0,
                ts_ns: 200,
                signal_name: "view".to_string(),
                annotation: None,
                session_seqno: None,
                idempotency_key: None,
            },
            SessionWalEvent::Signal {
                session_id: 1,
                entity_id: 43,
                weight: 2.0,
                ts_ns: 300,
                signal_name: "reward".to_string(),
                annotation: Some("good content".to_string()),
                session_seqno: None,
                idempotency_key: None,
            },
            SessionWalEvent::Close { session_id: 1 },
        ];

        let mut all_bytes = Vec::new();
        for event in &events {
            all_bytes.extend(encode_session_event(event));
        }

        let decoded = decode_session_events(&all_bytes);
        assert_eq!(decoded.len(), events.len());
        for (orig, dec) in events.iter().zip(decoded.iter()) {
            assert_eq!(orig, dec);
        }
    }

    #[test]
    fn session_decode_truncated_stops_cleanly() {
        let event = SessionWalEvent::Start {
            session_id: 1,
            user_id: 10,
            started_at_ns: 100,
            agent_id: "agent".to_string(),
            policy_name: "policy".to_string(),
        };
        let encoded = encode_session_event(&event);

        // Truncate the record mid-way.
        let truncated = &encoded[..encoded.len() / 2];
        let decoded = decode_session_events(truncated);
        assert!(decoded.is_empty(), "truncated record should be skipped");
    }

    #[test]
    fn session_decode_partial_second_record_stops() {
        let e1 = SessionWalEvent::Close { session_id: 1 };
        let e2 = SessionWalEvent::Start {
            session_id: 2,
            user_id: 20,
            started_at_ns: 200,
            agent_id: "agent".to_string(),
            policy_name: "policy".to_string(),
        };
        let mut all_bytes = encode_session_event(&e1);
        let e2_bytes = encode_session_event(&e2);
        // Add partial second record.
        all_bytes.extend_from_slice(&e2_bytes[..e2_bytes.len() / 2]);

        let decoded = decode_session_events(&all_bytes);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], e1);
    }

    #[test]
    fn session_decode_empty_bytes() {
        let decoded = decode_session_events(&[]);
        assert!(decoded.is_empty());
    }

    #[test]
    fn session_seqno_ordering() {
        assert!(SessionSeqNo(1) > SessionSeqNo(0));
        assert_eq!(SessionSeqNo(5).next(), SessionSeqNo(6));
        assert_eq!(SessionSeqNo::ZERO, SessionSeqNo(0));
    }

    #[test]
    fn session_signal_with_seqno_roundtrip() {
        let event = SessionWalEvent::Signal {
            session_id: 1,
            entity_id: 42,
            weight: 1.0,
            ts_ns: 1000,
            signal_name: "view".to_string(),
            annotation: None,
            session_seqno: Some(SessionSeqNo(7)),
            idempotency_key: Some(0xDEAD_BEEF_CAFE_BABE_u128),
        };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0], event);
    }

    #[test]
    fn session_signal_legacy_roundtrip_no_seqno() {
        // Legacy events (seqno=None) decode correctly.
        let event = SessionWalEvent::Signal {
            session_id: 2,
            entity_id: 99,
            weight: 2.0,
            ts_ns: 2000,
            signal_name: "like".to_string(),
            annotation: None,
            session_seqno: None,
            idempotency_key: None,
        };
        let encoded = encode_session_event(&event);
        let decoded = decode_session_events(&encoded);
        assert_eq!(decoded[0], event);
    }
}
