//! Opaque pagination cursor for keyset pagination.

use std::fmt;

use base64::Engine as _;

use super::errors::QueryError;
use crate::schema::EntityId;

// ── Cursor kind discriminant ──────────────────────────────────────────────────

/// Wire discriminant distinguishing a keyset cursor from a bare offset cursor.
///
/// Encoded as the first byte of the on-wire format so a keyset cursor handed to
/// an offset-only consumer (or vice versa) is a loud `InvalidCursor` error rather
/// than a silent misread of the payload as the wrong shape.
const KIND_KEYSET: u8 = 0;
const KIND_OFFSET: u8 = 1;

/// The two semantics a [`Cursor`] can carry.
///
/// A `Keyset` cursor anchors the next page on the `(score, entity_id)` of the
/// last row of the previous page. An `Offset` cursor anchors on a bare list
/// position. The RETRIEVE/SEARCH executors currently interpret cursors as
/// offsets; handing them a keyset cursor is rejected by [`Cursor::offset`]
/// rather than silently reinterpreting the keyset's entity ID as a position.
#[derive(Debug, Clone, Copy, PartialEq)]
enum CursorKind {
    /// Keyset anchor: `(last_score, last_id)` of the previous page's last row.
    Keyset { score: f64, id: EntityId },
    /// Bare list-position offset for offset-based pagination.
    Offset(u64),
}

// ── Cursor ──────────────────────────────────────────────────────────────────

/// Opaque pagination cursor for keyset pagination.
///
/// Encodes either a keyset anchor `(score, entity_id)` or a bare offset as a
/// base64url string. The cursor is opaque to callers -- they receive it from
/// `Results::next_cursor` and pass it back to `RetrieveBuilder::cursor()`.
///
/// The two shapes are tagged with a discriminant byte so a keyset cursor can
/// never be silently misread as an offset (or vice versa): [`Cursor::offset`]
/// returns an error on a keyset cursor, and the keyset accessors return an error
/// on an offset cursor.
///
/// Internal wire format: `{kind:1}{score_bits_be:8}{id_be:8}` (17 bytes),
/// base64url-encoded without padding. For an offset cursor the score field is
/// zero and the id field carries the offset.
#[derive(Debug, Clone, PartialEq)]
pub struct Cursor {
    kind: CursorKind,
}

impl Cursor {
    /// Create a keyset cursor from the score and entity ID of the previous
    /// page's last row.
    ///
    /// Non-finite scores (NaN/Inf) are clamped to `0.0`: a keyset anchor must be
    /// a real, totally-ordered value, and `encode`/`decode` reject non-finite
    /// scores on the wire, so admitting one here would only produce a cursor that
    /// cannot round-trip.
    #[must_use]
    pub const fn new(last_score: f64, last_id: EntityId) -> Self {
        let score = if last_score.is_finite() {
            last_score
        } else {
            0.0
        };
        Self {
            kind: CursorKind::Keyset { score, id: last_id },
        }
    }

    /// The score of the last result on the previous page.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidCursor` if this is an offset cursor, which
    /// carries no keyset score.
    pub fn last_score(&self) -> Result<f64, QueryError> {
        match self.kind {
            CursorKind::Keyset { score, .. } => Ok(score),
            CursorKind::Offset(_) => Err(QueryError::InvalidCursor(
                "offset cursor has no keyset score".to_owned(),
            )),
        }
    }

    /// The entity ID of the last result on the previous page.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidCursor` if this is an offset cursor, which
    /// carries no keyset entity ID.
    pub fn last_id(&self) -> Result<EntityId, QueryError> {
        match self.kind {
            CursorKind::Keyset { id, .. } => Ok(id),
            CursorKind::Offset(_) => Err(QueryError::InvalidCursor(
                "offset cursor has no keyset entity id".to_owned(),
            )),
        }
    }

    /// Create a cursor from a simple offset (for offset-based pagination).
    #[must_use]
    pub const fn from_offset(offset: usize) -> Self {
        // `usize as u64` is lossless on all targets tidalDB supports (<= 64-bit);
        // the inverse cast in `offset()` is the one that needs a guard.
        Self {
            kind: CursorKind::Offset(offset as u64),
        }
    }

    /// Extract the offset from a cursor created via `from_offset`.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidCursor` if:
    /// - this is a keyset cursor (its entity ID is not a page position), or
    /// - the stored offset does not fit in `usize` (e.g. a value above
    ///   `u32::MAX` decoded on a 32-bit target, or a crafted hostile cursor).
    ///
    /// Both cases previously truncated silently via `as usize`, changing the
    /// page position without any signal to the caller.
    pub fn offset(&self) -> Result<usize, QueryError> {
        let raw = match self.kind {
            CursorKind::Offset(raw) => raw,
            CursorKind::Keyset { .. } => {
                return Err(QueryError::InvalidCursor(
                    "keyset cursor cannot be used as a page offset".to_owned(),
                ));
            }
        };
        usize::try_from(raw).map_err(|_| {
            QueryError::InvalidCursor(format!("offset {raw} exceeds usize::MAX on this target"))
        })
    }

    /// Encode the cursor as an opaque base64url string.
    #[must_use]
    pub fn encode(&self) -> String {
        let mut buf = [0u8; 17];
        let (kind, score, id) = match self.kind {
            CursorKind::Keyset { score, id } => (KIND_KEYSET, score, id.as_u64()),
            CursorKind::Offset(raw) => (KIND_OFFSET, 0.0_f64, raw),
        };
        buf[0] = kind;
        buf[1..9].copy_from_slice(&score.to_be_bytes());
        buf[9..].copy_from_slice(&id.to_be_bytes());
        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
    }

    /// Decode a cursor from a base64url string.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidCursor` if the string cannot be decoded, has
    /// the wrong length, carries an unknown kind discriminant, or carries a
    /// non-finite (NaN/Inf) keyset score. Rejecting non-finite scores keeps a
    /// corrupt or hostile cursor from injecting a value that breaks the total
    /// ordering keyset pagination relies on.
    ///
    /// # Panics
    ///
    /// Does not panic in practice. The `unwrap` calls on byte-slice conversion
    /// are unreachable because the length is verified to be exactly 17 bytes.
    pub fn decode(s: &str) -> Result<Self, QueryError> {
        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .decode(s)
            .map_err(|e| QueryError::InvalidCursor(format!("base64 decode: {e}")))?;

        if bytes.len() != 17 {
            return Err(QueryError::InvalidCursor(format!(
                "expected 17 bytes, got {}",
                bytes.len()
            )));
        }

        // Safety: len == 17 verified above; slices are exactly 8 bytes.
        #[allow(clippy::unwrap_used)]
        let score_bytes = <[u8; 8]>::try_from(&bytes[1..9]).unwrap();
        #[allow(clippy::unwrap_used)]
        let id_bytes = <[u8; 8]>::try_from(&bytes[9..17]).unwrap();

        let score = f64::from_be_bytes(score_bytes);
        let id = u64::from_be_bytes(id_bytes);

        let kind = match bytes[0] {
            KIND_KEYSET => {
                if !score.is_finite() {
                    return Err(QueryError::InvalidCursor(format!(
                        "non-finite keyset score: {score}"
                    )));
                }
                CursorKind::Keyset {
                    score,
                    id: EntityId::new(id),
                }
            }
            KIND_OFFSET => CursorKind::Offset(id),
            other => {
                return Err(QueryError::InvalidCursor(format!(
                    "unknown cursor kind byte: {other}"
                )));
            }
        };

        Ok(Self { kind })
    }
}

impl fmt::Display for Cursor {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.encode())
    }
}

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

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use base64::Engine as _;

    use super::*;

    #[test]
    fn keyset_roundtrip() {
        let cursor = Cursor::new(0.95, EntityId::new(42));
        let decoded = Cursor::decode(&cursor.encode()).unwrap();
        assert_eq!(decoded, cursor);
        assert!((decoded.last_score().unwrap() - 0.95).abs() < f64::EPSILON);
        assert_eq!(decoded.last_id().unwrap(), EntityId::new(42));
    }

    #[test]
    fn offset_roundtrip() {
        let cursor = Cursor::from_offset(12_345);
        let decoded = Cursor::decode(&cursor.encode()).unwrap();
        assert_eq!(decoded, cursor);
        assert_eq!(decoded.offset().unwrap(), 12_345);
    }

    #[test]
    fn keyset_cursor_used_as_offset_is_a_loud_error() {
        // Finding 4: a keyset cursor must never be silently reinterpreted as an
        // offset (its entity ID is not a page position).
        let cursor = Cursor::new(0.5, EntityId::new(99));
        assert!(matches!(cursor.offset(), Err(QueryError::InvalidCursor(_))));
    }

    #[test]
    fn offset_cursor_has_no_keyset_accessors() {
        let cursor = Cursor::from_offset(7);
        assert!(matches!(
            cursor.last_score(),
            Err(QueryError::InvalidCursor(_))
        ));
        assert!(matches!(
            cursor.last_id(),
            Err(QueryError::InvalidCursor(_))
        ));
    }

    #[test]
    fn decode_invalid_base64() {
        assert!(matches!(
            Cursor::decode("!!!not-base64!!!"),
            Err(QueryError::InvalidCursor(_))
        ));
    }

    #[test]
    fn decode_wrong_length() {
        let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 8]);
        assert!(matches!(
            Cursor::decode(&short),
            Err(QueryError::InvalidCursor(_))
        ));
    }

    #[test]
    fn decode_unknown_kind_byte_rejected() {
        let mut buf = [0u8; 17];
        buf[0] = 99; // neither KIND_KEYSET nor KIND_OFFSET
        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
        assert!(matches!(
            Cursor::decode(&encoded),
            Err(QueryError::InvalidCursor(_))
        ));
    }

    #[test]
    fn decode_rejects_non_finite_keyset_score() {
        // Finding 5: a hostile/corrupt cursor must not inject NaN/Inf scores.
        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
            let mut buf = [0u8; 17];
            buf[0] = KIND_KEYSET;
            buf[1..9].copy_from_slice(&bad.to_be_bytes());
            buf[9..].copy_from_slice(&1u64.to_be_bytes());
            let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
            assert!(
                matches!(Cursor::decode(&encoded), Err(QueryError::InvalidCursor(_))),
                "non-finite score {bad} should be rejected"
            );
        }
    }

    #[test]
    fn new_clamps_non_finite_score_to_zero() {
        let cursor = Cursor::new(f64::NAN, EntityId::new(3));
        assert!(cursor.last_score().unwrap().abs() < f64::EPSILON);
        // And the clamped cursor still round-trips.
        assert_eq!(Cursor::decode(&cursor.encode()).unwrap(), cursor);
    }

    #[test]
    fn offset_rejects_value_above_u32_on_decode() {
        // Finding 2: a crafted offset cursor carrying a value > u32::MAX must not
        // truncate silently. On a 64-bit target it round-trips; on a 32-bit
        // target `offset()` errors. We assert the value survives decode here and
        // that `usize::try_from` is the only narrowing gate.
        let big: u64 = u64::from(u32::MAX) + 1;
        let mut buf = [0u8; 17];
        buf[0] = KIND_OFFSET;
        buf[9..].copy_from_slice(&big.to_be_bytes());
        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
        let cursor = Cursor::decode(&encoded).unwrap();
        // Re-encode preserves the full 64-bit value (no truncation in the codec).
        assert_eq!(Cursor::decode(&cursor.encode()).unwrap(), cursor);
        // On 64-bit, offset() returns the exact value; on 32-bit it would error.
        match cursor.offset() {
            Ok(v) => assert_eq!(u64::try_from(v).unwrap(), big),
            Err(QueryError::InvalidCursor(_)) => {}
            Err(other) => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn display_matches_encode() {
        let cursor = Cursor::new(0.5, EntityId::new(100));
        assert_eq!(cursor.to_string(), cursor.encode());
    }

    mod proptests {
        use proptest::prelude::*;

        use super::*;

        proptest! {
            #[test]
            fn keyset_roundtrip_prop(
                score in proptest::num::f64::NORMAL,
                id in 0u64..u64::MAX,
            ) {
                let cursor = Cursor::new(score, EntityId::new(id));
                let decoded = Cursor::decode(&cursor.encode()).unwrap();
                prop_assert_eq!(decoded.last_id().unwrap(), EntityId::new(id));
                // f64 roundtrip through be_bytes is exact for finite values.
                prop_assert!((decoded.last_score().unwrap() - score).abs() < f64::EPSILON);
            }

            #[test]
            fn offset_roundtrip_prop(offset in 0usize..=usize::MAX) {
                let cursor = Cursor::from_offset(offset);
                let decoded = Cursor::decode(&cursor.encode()).unwrap();
                prop_assert_eq!(decoded.offset().unwrap(), offset);
            }
        }
    }
}
